Records as Request and Response DTOs

Use Java records for request and response bodies: Jackson binds them through the canonical constructor, and they keep the API separate from entities.

Syntaxrecord CreateThingRequest(String name, int quantity) { CreateThingRequest { name = name == null ? null : name.strip(); } } record ThingResponse(UUID id, String name) {}

A DTO (data transfer object) is the shape of JSON your API accepts or returns. Records are the natural fit: they are immutable, they have value equality, and Jackson knows how to build them. A record's canonical constructor is used as the creator, with component names taken from the record itself, so you need no @JsonCreator and no -parameters compiler flag.

Separate shapes for in and out

Give each endpoint a request record and a response record, even when they look alike today. A CreateOrderRequest must not contain an id or status the client could set; an OrderResponse carries those and leaves out anything internal. Reusing the JPA entity for both leaks columns such as password hashes, lets clients write fields they should never touch (mass assignment), and ties your JSON to your schema so every migration becomes an API change.

Missing values

When a property is absent from the JSON, Jackson passes null for reference types. For primitives Spring Boot 4 rejects the request: a missing int quantity is a 400, not a silent 0. Choose deliberately. Use int when the value is required, and Integer when it is optional and you will apply a default.

Compact constructors

A compact constructor runs on every deserialisation, which makes it a good place for normalising: trimming and lower-casing an email, replacing a null list with an empty one, copying a list so callers cannot mutate it, or applying defaults for paging parameters. Keep business rules out of it. Throwing an exception from a constructor turns into a vague 400; field-level checks belong to Bean Validation, covered in the next category.

Mapping

A static from(entity) method on the response record keeps the mapping next to the shape it produces, and needs no mapping library. Build the response inside the transaction, so any lazy association you read is loaded while the session is still open.

Records nest naturally: a List<OrderLineRequest> inside CreateOrderRequest binds a JSON array of objects. Declare records that are only used by one controller as nested or package-private types beside it, so the public surface of your code stays small.

Example

Example · java
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

record OrderLineRequest(String sku, int quantity) {}

record CreateOrderRequest(String customerEmail, List<OrderLineRequest> lines, String note) {
    CreateOrderRequest {
        // Runs on every deserialisation: normalise, do not validate business rules here.
        customerEmail = customerEmail == null ? null : customerEmail.strip().toLowerCase();
        lines = lines == null ? List.of() : List.copyOf(lines);
    }
}

record OrderResponse(UUID id, String status, int itemCount, BigDecimal total, Instant createdAt) {}

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

    private static final BigDecimal UNIT_PRICE = new BigDecimal("9.99");

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    OrderResponse create(@RequestBody CreateOrderRequest request) {
        int items = request.lines().stream().mapToInt(OrderLineRequest::quantity).sum();
        BigDecimal total = UNIT_PRICE.multiply(BigDecimal.valueOf(items));
        return new OrderResponse(UUID.randomUUID(), "PENDING", items, total, Instant.now());
    }
}

When to use it

  • Accept a new order with nested line items without exposing the order's id, status or internal pricing fields to the client.
  • Return customers from a JPA repository without leaking the password hash column.
  • Give a search endpoint sensible paging defaults and a maximum page size when the client sends nothing.

More examples

Mapping an entity to a response record

The response record lists exactly what clients may see. Adding a column to <code>Customer</code> changes nothing in the API until you decide to add it here, which is the point: schema changes and API changes become separate decisions.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.time.Instant;

@Entity
class Customer {
    @Id
    @GeneratedValue
    private Long id;
    private String email;
    private String passwordHash;
    private Instant createdAt;

    protected Customer() {
    }

    Long getId() { return id; }
    String getEmail() { return email; }
    String getPasswordHash() { return passwordHash; }
    Instant getCreatedAt() { return createdAt; }
}

public record CustomerResponse(Long id, String email, Instant createdAt) {

    static CustomerResponse from(Customer customer) {
        // passwordHash never leaves the server because it is not a component here.
        return new CustomerResponse(customer.getId(), customer.getEmail(), customer.getCreatedAt());
    }
}

Optional fields with defaults

Wrapper types let the client leave a field out, and the compact constructor turns absent values into sensible defaults in one place. Capping <code>size</code> here protects the database from a client asking for a million rows.

Example · java
import java.util.List;

public record ProductSearchRequest(String query, Integer page, Integer size, List<String> tags) {

    static final int MAX_SIZE = 100;

    public ProductSearchRequest {
        query = query == null ? "" : query.strip();
        page = page == null || page < 0 ? 0 : page;
        size = size == null ? 20 : Math.min(size, MAX_SIZE);
        tags = tags == null ? List.of() : List.copyOf(tags);
    }
}

Proving a missing primitive is rejected

A slice test pins down the behaviour clients rely on: a body without <code>quantity</code> never reaches your controller. If someone later changes the component to <code>Integer</code>, the first test fails and the change is a conscious one. The <code>@Import</code> matters: Spring Boot's test filters skip classes nested inside a test class, so without it the controller is never registered and both requests get a 404.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@WebMvcTest(LineControllerTest.LineController.class)
// Classes nested in a test class are skipped by component scanning, so import it.
@Import(LineControllerTest.LineController.class)
class LineControllerTest {

    record LineRequest(String sku, int quantity) {}

    @RestController
    static class LineController {
        @PostMapping("/lines")
        LineRequest echo(@RequestBody LineRequest request) {
            return request;
        }
    }

    @Autowired
    MockMvcTester mvc;

    @Test
    void missingQuantityIsBadRequest() {
        assertThat(mvc.post().uri("/lines").contentType(MediaType.APPLICATION_JSON)
                .content("{\"sku\":\"A-1\"}"))
                .hasStatus(HttpStatus.BAD_REQUEST);
    }

    @Test
    void completeBodyRoundTrips() {
        assertThat(mvc.post().uri("/lines").contentType(MediaType.APPLICATION_JSON)
                .content("{\"sku\":\"A-1\",\"quantity\":2}"))
                .hasStatusOk()
                .bodyJson().isLenientlyEqualTo("{\"sku\":\"A-1\",\"quantity\":2}");
    }
}

Discussion

  • Be the first to comment on this lesson.