CRUD Operations with JpaRepository
You have entities and repositories set up. Now let’s work through every data operation in depth — create, read, update, delete — and the JPA mechanics behind each.
Create: save()
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
public Order createOrder(CreateOrderRequest request) {
Order order = new Order();
order.setCustomerId(request.customerId());
order.setOrderNumber(generateOrderNumber());
order.setStatus(OrderStatus.PENDING);
request.items().forEach(itemReq -> {
OrderItem item = new OrderItem();
item.setProductId(itemReq.productId());
item.setQuantity(itemReq.quantity());
item.setUnitPrice(itemReq.unitPrice());
order.addItem(item); // manages bidirectional relationship
});
return orderRepository.save(order);
}
}
save() behavior:
- If the entity has no ID →
INSERT - If the entity has an ID →
MERGE(upsert) — loads from DB and copies state, or inserts if not found
For UUID entities, Spring Data checks entity.getId() == null to decide between insert and merge.
Read: findById, findAll, and Custom Finders
// Throws if not found — better than returning null
public Order findById(UUID id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
// Returns null-safe Optional — let caller decide what to do
public Optional<Order> findOptionalById(UUID id) {
return orderRepository.findById(id);
}
// All orders — only use for small datasets
public List<Order> findAll() {
return orderRepository.findAll();
}
// Paginated — always prefer this
public Page<Order> findAll(Pageable pageable) {
return orderRepository.findAll(pageable);
}
// With derived query
public List<Order> findByCustomer(UUID customerId) {
return orderRepository.findByCustomerId(customerId);
}
// Check existence without loading the entity
public boolean orderExists(UUID id) {
return orderRepository.existsById(id);
}
getReferenceById vs findById
// findById: loads the entity immediately (SELECT query runs now)
Order order = orderRepository.findById(id).orElseThrow();
// getReferenceById: returns a proxy — no SELECT until you access a field
// Use this when you only need to set a FK relationship (avoids a wasted DB read)
Order orderProxy = orderRepository.getReferenceById(id);
item.setOrder(orderProxy); // sets the FK — no SELECT issued
Update: Dirty Checking
The cleanest update pattern in JPA:
@Transactional
public Order updateStatus(UUID id, OrderStatus newStatus) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
order.setStatus(newStatus); // modify the entity
// NO explicit save() needed — Hibernate detects the change
// and issues UPDATE at transaction commit
return order;
}
Hibernate compares the entity’s current state against a snapshot taken when it was loaded. Any difference → generates UPDATE SQL.
When to call save() explicitly
// Needed: entity not in persistence context (detached)
@Transactional
public Order updateDetached(Order detachedOrder) {
detachedOrder.setStatus(OrderStatus.CONFIRMED);
return orderRepository.save(detachedOrder); // reattaches and merges
}
// Needed: returning the updated entity for its new state
// (e.g., @Version was bumped, @LastModifiedDate changed)
@Transactional
public Order confirm(UUID id) {
Order order = orderRepository.findById(id).orElseThrow();
order.confirm();
return orderRepository.saveAndFlush(order); // flush immediately for @Version
}
Bulk updates with @Modifying
When updating many records at once, don’t load each entity — use a direct UPDATE query:
public interface OrderRepository extends JpaRepository<Order, UUID> {
@Modifying
@Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus")
int updateStatusBulk(@Param("oldStatus") OrderStatus old,
@Param("newStatus") OrderStatus newStatus);
@Modifying
@Query("UPDATE Order o SET o.status = 'CANCELLED' WHERE o.customerId = :cid")
int cancelAllByCustomer(@Param("cid") UUID customerId);
}
@Transactional
public int cancelAllForCustomer(UUID customerId) {
return orderRepository.cancelAllByCustomer(customerId);
}
@Modifying is required for any UPDATE or DELETE @Query. Add clearAutomatically = true if you load entities after the bulk update in the same transaction:
@Modifying(clearAutomatically = true) // clears persistence context cache
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
int bulkUpdateStatus(@Param("ids") List<UUID> ids, @Param("status") OrderStatus status);
Delete
// Delete by ID
orderRepository.deleteById(id);
// Delete loaded entity
Order order = orderRepository.findById(id).orElseThrow();
orderRepository.delete(order);
// Bulk delete
@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status AND o.createdAt < :before")
int deleteOldCancelled(@Param("status") OrderStatus status, @Param("before") Instant before);
// deleteBy derived method (loads each entity then deletes — use @Query for bulk)
orderRepository.deleteByStatus(OrderStatus.CANCELLED);
Batch Operations
Saving individual entities in a loop generates N INSERT statements. Batch them:
Enable JDBC batching
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
order_inserts: true
order_updates: true
Warning: Batching does NOT work with GenerationType.IDENTITY (auto-increment) because Hibernate needs the ID after each INSERT. Use SEQUENCE or UUID for batching.
@Transactional
public void importOrders(List<CreateOrderRequest> requests) {
List<Order> orders = requests.stream()
.map(this::toEntity)
.toList();
// saveAll batches with the batch_size from properties
orderRepository.saveAll(orders);
}
Manual batching with flush and clear
@Transactional
public void importLargeDataset(List<OrderData> data) {
int batchSize = 50;
for (int i = 0; i < data.size(); i++) {
Order order = toEntity(data.get(i));
em.persist(order);
if (i % batchSize == 0 && i > 0) {
em.flush(); // write to DB
em.clear(); // clear persistence context (free memory)
}
}
}
Without flush() + clear(), Hibernate holds all 10,000 entities in memory until the end of the transaction.
Dynamic Queries with Specification
When filters are optional and you can’t write all combinations as derived methods:
// Implement JpaSpecificationExecutor
public interface OrderRepository extends JpaRepository<Order, UUID>,
JpaSpecificationExecutor<Order> {}
// Build the specification dynamically
public class OrderSpecifications {
public static Specification<Order> hasCustomer(UUID customerId) {
return (root, query, cb) -> customerId == null
? cb.conjunction() // always true — no filter
: cb.equal(root.get("customerId"), customerId);
}
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) -> status == null
? cb.conjunction()
: cb.equal(root.get("status"), status);
}
public static Specification<Order> createdAfter(Instant from) {
return (root, query, cb) -> from == null
? cb.conjunction()
: cb.greaterThan(root.get("createdAt"), from);
}
public static Specification<Order> minAmount(BigDecimal min) {
return (root, query, cb) -> min == null
? cb.conjunction()
: cb.greaterThanOrEqualTo(root.get("totalAmount"), min);
}
}
@Service
public class OrderService {
public Page<Order> search(OrderSearchRequest req, Pageable pageable) {
Specification<Order> spec = Specification
.where(hasCustomer(req.customerId()))
.and(hasStatus(req.status()))
.and(createdAfter(req.from()))
.and(minAmount(req.minAmount()));
return orderRepository.findAll(spec, pageable);
}
}
Controlling Fetching: @EntityGraph
N+1 is the most common JPA performance problem. By default, collections are LAZY — each access triggers a separate SELECT:
// N+1 problem: 1 query for orders + N queries for items
List<Order> orders = orderRepository.findAll();
orders.forEach(o -> o.getItems().size()); // N additional queries!
Fix with @EntityGraph — tell JPA to JOIN FETCH the collections you need:
public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = {"items", "shippingAddress"})
List<Order> findByCustomerId(UUID customerId);
// Named EntityGraph (defined on entity)
@EntityGraph("Order.withItems")
Optional<Order> findById(UUID id);
}
Define named entity graphs on the entity:
@Entity
@NamedEntityGraph(
name = "Order.withItems",
attributeNodes = {
@NamedAttributeNode("items"),
@NamedAttributeNode(value = "customer", subgraph = "customer.address")
},
subgraphs = {
@NamedSubgraph(name = "customer.address",
attributeNodes = @NamedAttributeNode("address"))
}
)
public class Order { ... }
Use entity graphs when:
- You know upfront which associations you’ll access
- You need eager loading for a specific query (not globally)
Avoid putting fetch = FetchType.EAGER on @OneToMany — it applies globally and causes many queries in unexpected places.
The Complete Service Layer
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // default all methods to read-only
public class OrderService {
private final OrderRepository orderRepository;
public Page<Order> findAll(OrderFilter filter, Pageable pageable) {
return orderRepository.findAll(filter.toSpec(), pageable);
}
public Order findById(UUID id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@Transactional // override to writable for mutations
public Order create(CreateOrderRequest request) {
Order order = Order.from(request);
return orderRepository.save(order);
}
@Transactional
public Order confirm(UUID id) {
Order order = findById(id);
order.confirm(); // domain method — validates and changes state
return order; // dirty checking saves automatically
}
@Transactional
public boolean cancel(UUID id) {
return orderRepository.findById(id)
.map(order -> {
order.cancel();
return true;
})
.orElse(false);
}
@Transactional
public int cancelAllForCustomer(UUID customerId) {
return orderRepository.cancelAllByCustomer(customerId);
}
}
@Transactional(readOnly = true) at the class level is a best practice:
- Tells Hibernate to skip dirty checking (no need to track changes)
- Some DBs can route read-only transactions to read replicas
- Explicit
@Transactionalon mutating methods overrides to writable
What You’ve Learned
save()inserts if no ID, merges if ID present; dirty checking handles updates automaticallyfindById()loads immediately;getReferenceById()returns a proxy for FK-only usage@Modifying+@Queryfor bulk UPDATE/DELETE — don’t load entities you’re just going to delete- Enable JDBC batching with
hibernate.jdbc.batch_size— use UUID or SEQUENCE IDs (not IDENTITY) JpaSpecificationExecutor+Specificationfor dynamic filter queries@EntityGraphloads associations in a single JOIN query — prevents N+1 problems@Transactional(readOnly = true)at the class level; override with@Transactionalfor mutations
Next: Article 18 — Entity Relationships — @OneToMany, @ManyToOne, @ManyToMany, cascade, and orphan removal.