Records as DTOs

Use records for request and response shapes, keep them separate from your domain objects, and validate and copy data at the boundary.

Syntaxrecord OrderRequest(String customerId, List<LineItem> items) { OrderRequest { items = List.copyOf(items); // defensive, unmodifiable copy } }

A data transfer object (DTO) is the shape of data as it crosses a boundary: an HTTP request body, a JSON response, a message on a queue, a row from a query. It carries data and nothing else. Records are an excellent fit: one line declares the fields, the constructor, accessors, equals, hashCode and toString, and the result is shallowly immutable.

record CreateUserRequest(String email, String password) {}
record UserResponse(long id, String email, String role) {}

Why not return the domain object?

Serialising your internal User straight to JSON couples the API to your storage model and leaks whatever it holds, such as a password hash or an internal flag. Binding a request straight onto it is worse: a client that adds "role":"admin" to the body can set a field you never meant to expose, a bug known as mass assignment or over-posting. Separate records for what comes in and what goes out make the contract explicit, and a field that is not in the record cannot leak or be set.

Validate and normalise at the edge

A compact constructor runs on every construction, so checks there cannot be skipped. Use it for rules about the data itself: required fields, lengths, formats, normalising an email to lower case. Rules that need the database, such as "email already taken", belong in the service. Frameworks often use Bean Validation annotations on record components instead, which collect every error rather than stopping at the first; the idea is the same.

Immutable is only shallow

A record holding a List holds a reference. If the caller keeps and changes that list, the record changes too. Copy collections in the compact constructor with List.copyOf, which also makes them unmodifiable and rejects null elements. Array components are worse: the generated equals compares array references, not contents, so prefer List or wrap the bytes in a type of your own.

Mapping between layers

Keep conversions next to the DTO: a static from(User) factory on the response record and a toCommand() or similar on the request. The mapping is a few lines of plain code that the compiler checks, which is often simpler than a mapping library. For partial updates, a record whose components are null when not sent lets you tell "leave unchanged" apart from a real value. It cannot tell an omitted field from an explicit JSON null, so if clients must be able to clear a field, read the body as a tree or use a wrapper type such as Optional per field.

Things to watch

  • The generated toString prints every component. Override it on any DTO that carries a password or token, or it will end up in logs.
  • Records cannot be JPA entities, which need a no-argument constructor and mutable fields. They are fine as query projections and API shapes.
  • Jackson (from 2.12) and JSON-B map records by component name, so renaming a component changes the JSON contract.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Accept a sign-up request body without letting clients set the role or account status fields of the user entity.
  • Return order details from an endpoint without exposing internal columns such as cost price or supplier IDs.
  • Apply PATCH requests where omitted fields stay unchanged and explicitly sent values replace the stored ones.

More examples

Nested records and defensive copies

Without List.copyOf, the order would silently gain SKU-2 after it had been validated. Nested records map directly to nested JSON objects and arrays in a request body.

Example · java
record LineItem(String sku, int quantity) {}

record OrderRequest(String customerId, List<LineItem> items) {
    OrderRequest {
        items = List.copyOf(items);
        if (items.isEmpty()) {
            throw new IllegalArgumentException("an order needs at least one item");
        }
    }
}

void main() {
    List<LineItem> items = new ArrayList<>(List.of(new LineItem("SKU-1", 2)));
    var order = new OrderRequest("c-7", items);

    items.add(new LineItem("SKU-2", 1));   // the caller changes its own list
    IO.println("caller list: " + items.size() + ", order: " + order.items().size());

    try {
        order.items().add(new LineItem("SKU-3", 5));
    } catch (UnsupportedOperationException e) {
        IO.println("order items are read-only");
    }
    IO.println(order);
}

// Output:
// caller list: 2, order: 1
// order items are read-only
// OrderRequest[customerId=c-7, items=[LineItem[sku=SKU-1, quantity=2]]]

Value equality, and the array exception

Two records with equal components are equal, so they work as map keys and in test assertions without extra code. An array component breaks that because arrays compare by reference; use a List or override equals and hashCode.

Example · java
record Money(String currency, long minorUnits) {}
record Upload(String name, byte[] bytes) {}

void main() {
    Map<Money, String> labels = new HashMap<>();
    labels.put(new Money("GBP", 1999), "standard plan");
    IO.println("found by value: " + labels.get(new Money("GBP", 1999)));

    var a = new Upload("logo.png", new byte[] {1, 2, 3});
    var b = new Upload("logo.png", new byte[] {1, 2, 3});
    IO.println("uploads equal: " + a.equals(b));
    IO.println("bytes equal: " + Arrays.equals(a.bytes(), b.bytes()));
}

// Output:
// found by value: standard plan
// uploads equal: false
// bytes equal: true

Partial updates with a PATCH record

Records cannot be changed, so each with method returns a new copy and the original stays intact. An empty string is a real value that clears the note, while null leaves it alone.

Example · java
record OrderView(long id, String status, String note) {
    OrderView withStatus(String newStatus) {
        return new OrderView(id, newStatus, note);
    }

    OrderView withNote(String newNote) {
        return new OrderView(id, status, newNote);
    }
}

// null means "not sent", so leave that field unchanged.
record PatchOrderRequest(String status, String note) {}

static OrderView apply(OrderView current, PatchOrderRequest patch) {
    OrderView result = current;
    if (patch.status() != null) {
        result = result.withStatus(patch.status());
    }
    if (patch.note() != null) {
        result = result.withNote(patch.note());
    }
    return result;
}

void main() {
    var order = new OrderView(1001, "PENDING", "gift wrap");
    IO.println(apply(order, new PatchOrderRequest("SHIPPED", null)));
    IO.println(apply(order, new PatchOrderRequest(null, "")));
    IO.println("original: " + order);
}

// Output:
// OrderView[id=1001, status=SHIPPED, note=gift wrap]
// OrderView[id=1001, status=PENDING, note=]
// original: OrderView[id=1001, status=PENDING, note=gift wrap]

Discussion

  • Be the first to comment on this lesson.