Spring Data JPA can make a repository look like the whole persistence layer. Then a controller returns an entity, Jackson walks a lazy collection, Hibernate fires 100 queries, and the fix becomes “put @Transactional on the controller.”

The safer design starts with boundaries: entities live inside persistence; records cross the API boundary. A service transaction loads exactly the graph the use case needs, maps it to a record, and returns detached data that JSON can serialize without touching Hibernate.

This post continues REST API with Boot 3 + Records as DTOs. That API kept persistence behind explicit request and response records. Now we will add JPA without giving the database model control of the HTTP contract.

The architecture we want

Keep the transaction around the use case, not around JSON serialization:

HTTP request
    |
    v
@RestController
    |  request record -> command
    v
@Service
    |  @Transactional boundary
    |  repository query + entity changes
    |  entity -> response record
    v
@RestController
    |
    v
Jackson serializes records (no Hibernate session required)

Each type and layer has a different job:

  • Entity classes model persistence identity, relationships, and lifecycle.
  • Repositories execute queries and persist entity state.
  • Services own use cases, transaction boundaries, and fetch decisions.
  • Records describe stable request and response values.
  • Controllers translate HTTP into a service call.

The critical handoff happens before the service returns. If every field needed by the response has already been copied into a record, the web layer cannot accidentally trigger another SQL query.

Footgun 1: using records as entities

Records are excellent DTOs because they are concise, final value carriers with a canonical constructor. Those same properties conflict with the shape an ORM expects from an entity.

A JPA entity normally needs:

  • a public or protected no-argument constructor,
  • a non-final class and non-final persistent accessors when proxies are used,
  • persistence identity that remains stable while fields change,
  • and state that Hibernate can populate, track, and sometimes replace with lazy proxies.

A Java record is final, its components are final, and it cannot declare an extra no-argument constructor that bypasses its canonical state. Hibernate-specific features may support immutable projections and specialized mappings, but that does not make a record a portable, proxy-friendly JPA entity.

Do not put @Entity on an API record. You would be combining value equality, persistence identity, JSON shape, database shape, and lazy-loading behavior in one type.

Use a regular class for the entity:

package com.geekmonks.orders.persistence;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private String customerId;

    @OneToMany(
            mappedBy = "order",
            cascade = CascadeType.ALL,
            orphanRemoval = true,
            fetch = FetchType.LAZY
    )
    private List<OrderLine> lines = new ArrayList<>();

    protected PurchaseOrder() {
        // Required by JPA.
    }

    public PurchaseOrder(String customerId) {
        this.customerId = customerId;
    }

    public void addLine(String sku, int quantity) {
        lines.add(new OrderLine(this, sku, quantity));
    }

    public UUID getId() {
        return id;
    }

    public String getCustomerId() {
        return customerId;
    }

    public List<OrderLine> getLines() {
        return Collections.unmodifiableList(lines);
    }
}

The child entity owns the foreign key. Mark the @ManyToOne association lazy explicitly because JPA defaults to eager loading for to-one relationships:

package com.geekmonks.orders.persistence;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.util.UUID;

@Entity
@Table(name = "order_lines")
public class OrderLine {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private PurchaseOrder order;

    private String sku;
    private int quantity;

    protected OrderLine() {
        // Required by JPA.
    }

    OrderLine(PurchaseOrder order, String sku, int quantity) {
        this.order = order;
        this.sku = sku;
        this.quantity = quantity;
    }

    public String getSku() {
        return sku;
    }

    public int getQuantity() {
        return quantity;
    }
}

Keep response values separate:

package com.geekmonks.orders.api;

import java.util.List;
import java.util.UUID;

public record OrderResponse(
        UUID id,
        String customerId,
        List<LineResponse> lines
) {
    public OrderResponse {
        lines = List.copyOf(lines);
    }

    public record LineResponse(String sku, int quantity) {}
}

The entity may change to satisfy the database without silently changing the public JSON contract.

Footgun 2: treating LAZY as “never loaded”

FetchType.LAZY does not mean Hibernate avoids the relationship forever. It means access may trigger a query later while the persistence context is open.

Consider a repository call that loads ten orders:

List<PurchaseOrder> orders = repository.findAll();

return orders.stream()
        .map(order -> new OrderResponse(
                order.getId(),
                order.getCustomerId(),
                order.getLines().stream()
                        .map(line -> new LineResponse(
                                line.getSku(), line.getQuantity()))
                        .toList()))
        .toList();

The first query loads the orders. Accessing getLines() can then run one query per order:

select ... from purchase_orders;
select ... from order_lines where order_id = ?; -- order 1
select ... from order_lines where order_id = ?; -- order 2
select ... from order_lines where order_id = ?; -- order 3
...

That is the N+1 problem: one query for the parent list, then N relationship queries. It often survives local testing because three rows are fast and fails after production data grows.

If the persistence context is already closed, the same access does not become cheaper. It usually becomes a LazyInitializationException.

Choose a fetch plan per use case

Do not “fix” N+1 by changing every association to EAGER. Global eager loading over-fetches on endpoints that do not need the relationship and can create larger joins or additional selects.

For a use case that needs orders with their lines, declare that graph on the repository query:

package com.geekmonks.orders.persistence;

import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface PurchaseOrderRepository
        extends JpaRepository<PurchaseOrder, UUID> {

    @EntityGraph(attributePaths = "lines")
    @Query("select order from PurchaseOrder order order by order.id")
    List<PurchaseOrder> findAllWithLines();
}

An explicit fetch join is another option:

@Query("""
       select distinct order
       from PurchaseOrder order
       left join fetch order.lines
       order by order.id
       """)
List<PurchaseOrder> findAllWithLines();

Both approaches tell Hibernate that this query needs lines. Verify the generated SQL rather than assuming an annotation produced the plan you intended.

Note: Fetch-joining a collection can duplicate database rows, and collection fetch joins interact poorly with offset pagination. For a paged list, query the page of parent ids first and load details separately, or return a purpose-built projection that contains only the summary fields.

Observe queries in the lab

During development, enable SQL and Hibernate statistics:

spring:
  jpa:
    open-in-view: false
    properties:
      hibernate:
        generate_statistics: true

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE
    org.hibernate.stat: DEBUG

open-in-view: false is deliberate. It makes accidental lazy access in controllers or serializers fail during development instead of quietly querying the database after the service returns.

SQL logging is noisy and bind values can contain sensitive data. Use it locally or in controlled diagnostics, not as permanent production debug logging.

Footgun 3: putting transactions in the wrong layer

A transaction should cover one application operation and all persistence work needed to make that operation consistent.

The service is usually that boundary:

package com.geekmonks.orders;

import com.geekmonks.orders.api.OrderResponse;
import com.geekmonks.orders.api.OrderResponse.LineResponse;
import com.geekmonks.orders.persistence.PurchaseOrder;
import com.geekmonks.orders.persistence.PurchaseOrderRepository;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final PurchaseOrderRepository repository;

    public OrderService(PurchaseOrderRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public OrderResponse create(CreateOrder command) {
        var order = new PurchaseOrder(command.customerId());
        command.lines().forEach(line ->
                order.addLine(line.sku(), line.quantity()));

        return toResponse(repository.save(order));
    }

    @Transactional(readOnly = true)
    public List<OrderResponse> findAll() {
        return repository.findAllWithLines().stream()
                .map(OrderService::toResponse)
                .toList();
    }

    private static OrderResponse toResponse(PurchaseOrder order) {
        var lines = order.getLines().stream()
                .map(line -> new LineResponse(
                        line.getSku(), line.getQuantity()))
                .toList();

        return new OrderResponse(
                order.getId(), order.getCustomerId(), lines);
    }

    public record CreateOrder(String customerId, List<Line> lines) {
        public CreateOrder {
            lines = List.copyOf(lines);
        }
    }

    public record Line(String sku, int quantity) {}
}

The response mapping runs while the transaction and persistence context are active. Lazy state included by the fetch plan is available, and the returned record no longer depends on Hibernate.

readOnly = true is a useful intent and optimization hint. It is not a security control and does not guarantee that no write can occur at the database.

Keep controllers outside the persistence context

The controller accepts API records and returns API records:

package com.geekmonks.orders.api;

import com.geekmonks.orders.OrderService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orders;

    public OrderController(OrderService orders) {
        this.orders = orders;
    }

    @PostMapping
    OrderResponse create(@Valid @RequestBody CreateOrderRequest request) {
        var command = new OrderService.CreateOrder(
                request.customerId(),
                request.lines().stream()
                        .map(line -> new OrderService.Line(
                                line.sku(), line.quantity()))
                        .toList());

        return orders.create(command);
    }

    @GetMapping
    List<OrderResponse> findAll() {
        return orders.findAll();
    }

    public record CreateOrderRequest(
            @NotBlank String customerId,
            @NotEmpty List<@Valid CreateLineRequest> lines
    ) {}

    public record CreateLineRequest(
            @NotBlank String sku,
            @Positive int quantity
    ) {}
}

There is no @Transactional on the controller. Network input parsing and JSON output do not need to hold a database connection or persistence context open.

Know the proxy boundary

Spring usually applies @Transactional through a proxy. A call from another bean enters the proxy and starts the transaction. A method calling another @Transactional method on this does not pass through that proxy, so the inner annotation does not create a new boundary.

public void importOrders() {
    saveOne(); // Self-invocation: saveOne's proxy advice is bypassed.
}

@Transactional
public void saveOne() {
    // ...
}

Put the public use-case boundary on the method called by another component. If operations genuinely need different propagation rules, move them to separate services so the call crosses a proxy.

Also remember the default rollback rule: Spring rolls back for unchecked exceptions and Error, but not every checked exception. Configure rollbackFor only when the use case requires different semantics.

Run the lab

Add Spring Web, Validation, Spring Data JPA, and an H2 runtime database:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
  </dependency>
</dependencies>

Start the application:

./mvnw spring-boot:run

Create two orders:

curl -sS -X POST http://localhost:8080/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerId":"customer-42","lines":[
    {"sku":"jpa-guide","quantity":1},
    {"sku":"java-records","quantity":2}
  ]}'

curl -sS -X POST http://localhost:8080/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerId":"customer-84","lines":[
    {"sku":"spring-security","quantity":1}
  ]}'

Load the list:

curl -sS http://localhost:8080/api/orders

With findAllWithLines(), inspect the SQL log and confirm that Hibernate does not issue one line query for every order. Then temporarily replace it with findAll() and repeat the request. The statistics log should expose the extra statements.

This comparison is more valuable than memorizing “use @EntityGraph.” The habit is: define the response shape, choose a matching fetch plan, and measure the SQL.

Production checklist

Entity:
  regular class, protected no-arg constructor
  persistence identity and relationship methods
  not serialized as the public API

API:
  request and response records
  validation on request components
  defensive copies for collection components

Query:
  LAZY by default for collections
  explicit fetch plan for each use case
  inspect SQL and query counts
  avoid collection fetch joins with naive pagination

Transaction:
  boundary on the service use case
  readOnly = true for reads where useful
  map entity -> record before returning
  open-in-view disabled
  do not rely on self-invoked @Transactional methods

Wrap-up

Records and entities are not competing ways to remove boilerplate. They model different semantics. An entity has persistence identity, lifecycle, and ORM constraints; a record is a transparent value that makes an excellent HTTP contract.

Keep Hibernate behavior inside a service-owned transaction. Load the graph the use case needs, map entities to response records while that transaction is active, and let Jackson serialize detached values. That design turns N+1 queries, lazy loading, and transaction scope into explicit decisions instead of production surprises.

Next optional step Authorize business actions at the service, not only at the edge. Spring Security Basics at the Service