Java Records for Cleaner Data Transfer Objects
Data transfer objects (DTOs) often begin as simple carrier classes and accumulate routine syntax. A client identifier, customer name, timestamp and status may need fields, constructors, getters, equality logic and a readable string representation before crossing a service boundary.
Java records, finalised in Java 16, replace much of that boilerplate with a compact declaration. The compiler supplies a canonical constructor, accessors, component-based equality, a hash code and a diagnostic string, making the declared components the visible contract.
They suit request and response models built with Spring, Jackson and messaging frameworks. They do not remove the need to decide how null values, validation, dates and schema changes should behave.
What a record changes
A record suits data defined by its contents rather than a mutable lifecycle. A basic declaration remains compact:
public record CustomerSummary(
long id,
String name,
LocalDate joinedOn
) {}
Call sites use component-named accessors such as summary.name(), rather than JavaBean getters. Generated equals() and hashCode() compare component values, which makes records convenient in tests and collections.
Every component reference is final, but a record is only shallowly immutable. A mutable list inside it can still change, so defensively copy top-level collections with List.copyOf or Map.copyOf in a compact constructor.
Designing payloads for real APIs
A transfer object should mirror the message it accepts or returns rather than expose an entire domain model. Nested records can describe addresses, payments and line items without extra wrapper classes:
record AddressPayload(
String street,
String suburb,
String state,
String postcode
) {}
record CustomerPayload(
long id,
String name,
List<AddressPayload> addresses
) {
public CustomerPayload {
addresses = List.copyOf(addresses);
}
}
The compact constructor shown here takes a defensive copy of the address list, preventing a caller from changing the payload after construction.
Jakarta Bean Validation can annotate record components directly:
public record CreateCustomerRequest(
@NotBlank String name,
@Email String email,
@NotNull LocalDate joinedOn
) {}
Australian clients commonly expect dates in day/month/year order, while addresses use four-digit postcodes and state abbreviations such as NSW, VIC and QLD. Keeping the API payload in ISO 8601 and formatting only at the presentation edge avoids ambiguity. Bean Validation can enforce required fields and accepted state values.
Records in Spring and JSON
Current Jackson releases can materialise records directly from JSON, while Spring Boot’s MVC and WebFlux stacks can bind them without a custom deserialiser. The canonical constructor is the single point where incoming component values are assigned.
A Spring controller can therefore accept a validated request record and return an immutable response:
@PostMapping("/customers")
public ResponseEntity<CustomerSummary> create(
@Valid @RequestBody CreateCustomerRequest request) {
CustomerSummary created = customerService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
A missing JSON property becomes null for object components or the default value for primitives, such as zero for long. Define nullability rules and reject absent required values with Bean Validation instead of relying on accidental defaults.
For a Sydney or Melbourne consultancy, one payload model may need to work across client estates on Java 17 and newer Java 21 services. This mixed-estate pattern is familiar in the Australian market; when implementation support extends beyond the internal codebase, the consulting resource pages offer a useful external reference.
A disciplined adoption checklist
Records solve a representation problem, not every modelling problem. Before standardising them, agree on where they belong and which guarantees the surrounding framework provides.
Use records for API, event and configuration payloads rather than default JPA entities. Validate required components with Jakarta constraints and compact constructors. Copy mutable collections before storing them in a record. Prefer LocalDate, Instant and BigDecimal over ambiguous strings and floating-point values. Treat generated toString() output as potentially sensitive when payloads contain personal data. Test JSON binding for missing, null, unknown and nested properties, including contract changes.
In an Australian billing API, an immutable request might carry a customer identifier, service plan, subtotal, GST and issue date. BigDecimal preserves decimal amounts for AUD and tax calculations, while a LocalDate avoids an unnecessary time zone on a calendar date.
Keeping records at the right boundary
A record is not a drop-in replacement for a Hibernate entity. JPA commonly needs a no-argument constructor, non-final fields and proxy-friendly classes, whereas records expose final state and a canonical constructor. Keep mutable persistence entities inside the data layer and translate them into records for controllers or integrations.
Time handling deserves an explicit contract. Use LocalDate for calendar dates, Instant for an absolute event time and OffsetDateTime or ZonedDateTime when an offset or zone is part of the message. For an Australian service, convert UTC instants to Australia/Sydney for display so daylight-saving transitions are handled by the time-zone rules.
Records also do not solve compatibility by themselves. Adding a component changes the canonical constructor, and removing or renaming one can break clients. Evolve JSON contracts deliberately, document nullable fields and run deserialisation tests against retained example payloads.
The sweet spot is a boundary value that is created once and then read across layers. In that role, a record makes constructor data explicit, removes repetitive accessors and keeps the transfer model small without disguising the more complex lifecycle of a domain entity.