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 @Transactional on mutating methods overrides to writable

What You’ve Learned

  • save() inserts if no ID, merges if ID present; dirty checking handles updates automatically
  • findById() loads immediately; getReferenceById() returns a proxy for FK-only usage
  • @Modifying + @Query for 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 + Specification for dynamic filter queries
  • @EntityGraph loads associations in a single JOIN query — prevents N+1 problems
  • @Transactional(readOnly = true) at the class level; override with @Transactional for mutations

Next: Article 18 — Entity Relationships@OneToMany, @ManyToOne, @ManyToMany, cascade, and orphan removal.