Mapping Between Entities and DTOs

Keep JPA entities inside the service layer and convert them to record DTOs at the boundary, with plain mapping code you can read and test.

Syntaxrecord XxxRequest(...) { } // what clients send record XxxResponse(...) { // what clients receive static XxxResponse from(Xxx entity) { ... } } // service: map inside the transaction, return DTOs

An entity describes a table. A DTO (data transfer object) describes what a client sends or receives. They start out looking alike, which tempts people to return entities straight from controllers. That shortcut costs more later than the mapping code it saves.

What goes wrong with entities on the wire

  • Leaks. A passwordHash or internalNotes column added next year appears in the JSON the day it is added.
  • Lazy loading. Jackson walks every getter. A lazy @OneToMany either fires one query per row while serialising or fails with LazyInitializationException once spring.jpa.open-in-view is off.
  • Cycles. Order refers to Customer, which lists its orders, and the serialiser recurses.
  • Mass assignment. Binding a request body straight onto an entity lets a client set id, role or version.
  • Coupling. Renaming a column renames a JSON field and breaks every client.

Records as DTOs

Java records make DTOs cheap: one line per shape, immutable, with Jackson support built in. Use separate types for input and output, because they differ: a create request has no id, a response has no password.

record CreateCustomerRequest(@NotBlank String name, @Email String email) { }
record CustomerResponse(long id, String name, String email, Instant createdAt) { }

Where the mapping happens

Map inside the service, while the transaction is still open, and return DTOs to the controller. Lazy associations can then be read safely, and the controller never sees a managed entity. A static factory on the record (CustomerResponse.from(customer)) or a small package-private mapper class keeps the code in one place.

For input, do not copy every field of the request onto the entity. Call a constructor or a domain method with the fields the operation allows. An update request becomes customer.rename(request.name()), not a series of setters.

Hand-written or generated

Mapping libraries such as MapStruct generate this code at compile time. They pay off when you have dozens of large, near-identical shapes. For most APIs a few lines of Java per type are clearer, show up in a debugger, and fail at compile time when a field is renamed. Avoid reflection-based mappers that copy fields by name at runtime: a renamed field silently stops being copied.

For read-heavy endpoints, Spring Data can skip the entity entirely: a repository method can return a record projection, which selects only the columns the response needs.

For pages, call page.map(CustomerResponse::from) and wrap the result in Spring Data's PagedModel, which gives clients a stable content plus page metadata shape instead of the internal layout of PageImpl. Setting spring.data.web.pageable.serialization-mode=via-dto applies the same shape to every endpoint that returns a Page; the default is still direct.

Example

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.time.Clock;
import java.time.Instant;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Entity
class Customer {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private String email;
    private String passwordHash;   // must never reach a client
    private Instant createdAt;

    protected Customer() {
    }

    Customer(String name, String email, String passwordHash, Instant createdAt) {
        this.name = name;
        this.email = email;
        this.passwordHash = passwordHash;
        this.createdAt = createdAt;
    }

    void rename(String newName) {
        this.name = newName;
    }

    Long getId() { return id; }
    String getName() { return name; }
    String getEmail() { return email; }
    Instant getCreatedAt() { return createdAt; }
}

class CustomerNotFoundException extends RuntimeException {
    CustomerNotFoundException(long id) {
        super("Customer " + id + " not found");
    }
}

interface CustomerRepository extends JpaRepository<Customer, Long> {
}

record CreateCustomerRequest(@NotBlank String name, @Email @NotBlank String email) {
}

record RenameCustomerRequest(@NotBlank String name) {
}

record CustomerResponse(long id, String name, String email, Instant createdAt) {

    static CustomerResponse from(Customer c) {
        return new CustomerResponse(c.getId(), c.getName(), c.getEmail(), c.getCreatedAt());
    }
}

@Service
@Transactional(readOnly = true)
public class CustomerService {

    private final CustomerRepository customers;
    private final Clock clock;   // a Clock bean, so tests can fix the time

    CustomerService(CustomerRepository customers, Clock clock) {
        this.customers = customers;
        this.clock = clock;
    }

    @Transactional
    public CustomerResponse create(CreateCustomerRequest request, String passwordHash) {
        Customer customer = new Customer(request.name(), request.email(), passwordHash, clock.instant());
        return CustomerResponse.from(customers.save(customer));
    }

    @Transactional
    public CustomerResponse rename(long id, RenameCustomerRequest request) {
        Customer customer = customers.findById(id).orElseThrow(() -> new CustomerNotFoundException(id));
        customer.rename(request.name());   // only the field this operation allows
        return CustomerResponse.from(customer);
    }

    public Page<CustomerResponse> list(Pageable pageable) {
        return customers.findAll(pageable).map(CustomerResponse::from);
    }
}

When to use it

  • A user API stops leaking a passwordHash column that was added to the entity after the endpoint was written.
  • An order detail endpoint that threw LazyInitializationException is fixed by mapping to records inside the read-only service method.
  • A product list endpoint switches to a record projection and stops reading a large description column for every row.

More examples

A nested response built inside the transaction

The entity graph loads the order and its lines in one query, and the mapping runs before the transaction ends. The response records have no link back from a line to its order, so the JSON cannot loop, and the controller receives plain immutable data that is safe to serialise after the persistence context is closed.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Entity
class PurchaseOrder {
    @Id
    @GeneratedValue
    Long id;
    String reference;
    @OneToMany(mappedBy = "order")
    List<OrderLine> lines = new ArrayList<>();
}

@Entity
class OrderLine {
    @Id
    @GeneratedValue
    Long id;
    String sku;
    int quantity;
    @ManyToOne(fetch = FetchType.LAZY)
    PurchaseOrder order;
}

interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    @EntityGraph(attributePaths = "lines")
    Optional<PurchaseOrder> findWithLinesById(Long id);
}

record OrderLineResponse(String sku, int quantity) {
}

record OrderResponse(long id, String reference, List<OrderLineResponse> lines) {
}

@Service
public class OrderQueries {

    private final PurchaseOrderRepository orders;

    OrderQueries(PurchaseOrderRepository orders) {
        this.orders = orders;
    }

    @Transactional(readOnly = true)
    public OrderResponse get(long id) {
        PurchaseOrder order = orders.findWithLinesById(id).orElseThrow();
        List<OrderLineResponse> lines = order.lines.stream()
                .map(l -> new OrderLineResponse(l.sku, l.quantity))
                .toList();
        // No back-reference from line to order in the DTO, so no cycle in the JSON.
        return new OrderResponse(order.id, order.reference, lines);
    }
}

A record projection that skips the entity

For list screens you often need two columns, not the whole row. A derived query that returns a record selects only the record's properties, so the large <code>body</code> column is never read and nothing needs mapping. The second method takes the projection type as a parameter, so a search endpoint can ask for <code>ArticleSummary.class</code> while an admin export asks for the full <code>Article.class</code> from the same query.

Example · java
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
class Article {
    @Id
    Long id;
    String title;
    String body;          // large column the list view does not need
    boolean published;
}

record ArticleSummary(Long id, String title) {
}

public interface ArticleRepository extends JpaRepository<Article, Long> {

    // Spring Data maps the selected columns straight onto the record.
    List<ArticleSummary> findByPublishedTrueOrderByIdDesc();

    // Dynamic projection: the caller picks the shape per call.
    <T> List<T> findByTitleContainingIgnoreCase(String term, Class<T> type);
}

Wrapping a page for a stable JSON shape

Serialising a <code>Page</code> directly exposes the internal fields of <code>PageImpl</code>, which are not a stable contract. <code>PagedModel</code> produces a fixed <code>content</code> array plus a <code>page</code> object with size, number, total elements and total pages. The service still does the entity-to-DTO mapping; the controller only chooses the envelope.

Example · java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PagedModel;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

record BookResponse(long id, String title) {
}

@Service
class BookQueries {
    Page<BookResponse> list(Pageable pageable) {
        return new PageImpl<>(List.of(new BookResponse(1, "Dune")), pageable, 1);
    }
}

@RestController
public class BookController {

    private final BookQueries books;

    BookController(BookQueries books) {
        this.books = books;
    }

    @GetMapping("/api/books")
    PagedModel<BookResponse> list(Pageable pageable) {
        return new PagedModel<>(books.list(pageable));
    }
}

Discussion

  • Be the first to comment on this lesson.