PrepZone Logo
PrepZone

DTOs, Records, and Entity Mapping

Why you never expose entities on REST APIs and how records make DTOs effortless.

Why this matters

  • Exposing @Entity objects on REST APIs leaks internal fields, creates tight coupling, and enables lazy-loading exceptions mid-serialisation.
  • Records (Java 16+) eliminate boilerplate for request and response types.
  • Separate read and write DTOs prevent clients from overwriting fields they should not touch (like id or createdAt).
ControllerHTTP in/out — @RestController
ServiceBusiness rules — @Service
RepositoryDatabase — @Repository
DatabasePostgreSQL / H2
Each layer has one job. Dependencies point inward — controllers never touch the database directly.

Why not return entities?

Java
// BAD: entity on the wire
@GetMapping("/{id}")
public Book get(@PathVariable Long id) {
    return bookService.findById(id);  // exposes all fields, lazy relations
}

Problems:

Why entities must not leak to HTTP

  • Internal fields (version, audit columns) become visible.
  • Changing the database schema breaks API clients.
  • Lazy-loaded @ManyToOne relations trigger LazyInitializationException during JSON serialisation.

Response DTO as a record

Java
public record BookResponse(
    Long id,
    String title,
    String isbn,
    BigDecimal price
) {
    public static BookResponse from(Book book) {
        return new BookResponse(
            book.getId(), book.getTitle(), book.getIsbn(), book.getPrice());
    }
}

Immutable, concise, and serialisable by Jackson without configuration.

Request DTOs: separate create and update

Java
public record CreateBookRequest(
    @NotBlank String title,
    @NotBlank @Pattern(regexp = "\\d{13}", message = "ISBN must be 13 digits")
    String isbn,
    @NotNull @Positive BigDecimal price
) {}

public record UpdateBookRequest(
    @NotBlank String title,
    @NotNull @Positive BigDecimal price
) {}

Create includes isbn; update does not — clients cannot change an ISBN after creation.

DTO design rules for BookStore

  • One DTO per use case — CreateBookRequest, UpdateBookRequest, BookResponse, BookSummary.
  • Records for immutability — No setters means no accidental mutation after validation.
  • Validation on input DTOs — @NotBlank, @Positive on request records, not entities.
  • Static factory from() — Map entity to response in one place.

Mapping in the controller

Java
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public BookResponse create(@RequestBody @Valid CreateBookRequest request) {
    Book book = bookService.create(request);
    return BookResponse.from(book);
}

Controller converts inbound DTO → service call → outbound DTO. Service works with entities.

Mapping in the service

When mapping logic grows beyond a one-liner:

Java
@Service
public class BookService {
    public Book create(CreateBookRequest request) {
        Book book = new Book(request.title(), request.isbn(), request.price());
        return repository.save(book);
    }
}

For complex mappings across many fields, consider MapStruct:

Java
@Mapper(componentModel = "spring")
public interface BookMapper {
    BookResponse toResponse(Book book);
    Book toEntity(CreateBookRequest request);
}

MapStruct generates implementation at compile time — no reflection overhead.

List endpoints with summary DTOs

Full BookResponse for detail views; lighter BookSummary for lists:

Java
public record BookSummary(Long id, String title, BigDecimal price) {
    public static BookSummary from(Book book) {
        return new BookSummary(book.getId(), book.getTitle(), book.getPrice());
    }
}

@GetMapping
public List<BookSummary> list() {
    return bookService.findAll().stream()
        .map(BookSummary::from)
        .toList();
}

Smaller payloads, faster serialisation, less bandwidth.

Nested DTOs

Java
public record AuthorResponse(Long id, String name) {}

public record BookDetailResponse(
    Long id,
    String title,
    String isbn,
    BigDecimal price,
    AuthorResponse author
) {
    public static BookDetailResponse from(Book book) {
        return new BookDetailResponse(
            book.getId(), book.getTitle(), book.getIsbn(), book.getPrice(),
            new AuthorResponse(book.getAuthor().getId(), book.getAuthor().getName())
        );
    }
}

Fetch the author eagerly in the service to avoid lazy-loading during mapping.

Quick recall

Everything you need if you only revisit this box.

  • DTOs decouple the API contract from the database schema.
  • Java records are ideal for immutable request/response types.
  • Separate DTOs per use case: create, update, detail, summary.
  • Map entities to DTOs via static from() factories or MapStruct.
  • Validate input on request DTOs with @Valid and Bean Validation annotations.
  • Never return entities — lazy relations cause serialisation failures.

Test yourself

Answer these before moving on — recall is what makes it stick.