How to use Java records as request and response DTOs with Jackson in Spring Boot — covering deserialisation, validation, nested records, and the cases where records don't fit.
Before Java records, a DTO in a Spring Boot REST controller looked like a class with private fields, a no-arg constructor, getters, setters, equals, hashCode, and toString — usually eight to fifteen lines per field, almost all of it boilerplate generated by an IDE or Lombok. Records collapse that to a single declaration. Understanding what Jackson does with them, and where the rough edges are, lets you adopt them confidently.
public record PlaceOrderRequest(
String marketId,
String selectionId,
double price,
double size,
String side
) {}
Jackson can deserialise JSON into this record out of the box since Jackson 2.12 with the jackson-module-parameter-names module enabled — which Spring Boot auto-configures. A POST body like:
{ "marketId": "1.234567", "selectionId": "789", "price": 2.5, "size": 10.0, "side": "BACK" }
maps to new PlaceOrderRequest("1.234567", "789", 2.5, 10.0, "BACK") automatically.
Controller usage:
@PostMapping("/orders")
public ResponseEntity<OrderDto> placeOrder(@RequestBody PlaceOrderRequest req) {
OrderDto created = orderService.place(req);
return ResponseEntity.created(URI.create("/orders/" + created.id())).body(created);
}
Records serialise cleanly to JSON — Jackson calls the accessor methods (not getters — records expose price() not getPrice()). The generated JSON uses the component names as keys:
{ "marketId": "1.234567", "selectionId": "789", "price": 2.5, "size": 10.0, "side": "BACK" }
No configuration needed.
Add jakarta.validation annotations directly on the record components:
public record PlaceOrderRequest(
@NotBlank String marketId,
@NotBlank String selectionId,
@DecimalMin("1.01") @DecimalMax("1000.0") double price,
@Positive double size,
@Pattern(regexp = "BACK|LAY") String side
) {}
With @Valid on the @RequestBody parameter, Spring Boot validates the incoming request before it reaches your controller method. Constraint violations return a 400 with field-level error details.
Cross-field validation (checking that two components are consistent with each other) belongs in a compact constructor:
public record DateRange(LocalDate from, LocalDate to) {
public DateRange {
Objects.requireNonNull(from, "from required");
Objects.requireNonNull(to, "to required");
if (to.isBefore(from)) {
throw new IllegalArgumentException("to must not be before from");
}
}
}
This validation runs on every construction path, including deserialisation.
Records compose well. A response DTO for a market might nest runner DTOs:
public record MarketDto(
String marketId,
String marketName,
Instant startTime,
List<RunnerDto> runners
) {}
public record RunnerDto(
long selectionId,
String runnerName,
double bestBack,
double bestLay
) {}
Jackson handles nested records without configuration — it recursively deserialises and serialises each level.
If your JSON uses snake_case or a different naming convention, use @JsonProperty:
public record RunnerDto(
@JsonProperty("selection_id") long selectionId,
@JsonProperty("runner_name") String runnerName
) {}
Or configure ObjectMapper globally with PropertyNamingStrategies.SNAKE_CASE if the whole API uses snake_case.
When a field must be omitted from serialisation: @JsonIgnore works on record components, but a DTO that needs to hide fields is often better as a separate response model that doesn’t include them at all.
When you need @JsonUnwrapped: Jackson’s unwrapping feature (including a nested object’s fields at the parent level) does not work with records — it requires a no-arg constructor for the type being unwrapped.
When the DTO changes after construction: Records are immutable. If you need to enrich a DTO in stages — adding fields from different sources before returning the response — a mutable class with a builder is cleaner.
When serialisation format diverges significantly from component names: A record with heavy @JsonProperty annotations on every component becomes noisy. If the JSON shape doesn’t match the record shape, a @JsonCreator factory method or a dedicated mapper is more maintainable.
For precise control over deserialisation — useful when the JSON field names differ significantly from the record components — use @JsonCreator:
public record MarketFilter(String marketType, List<String> countryCodes) {
@JsonCreator
public static MarketFilter of(
@JsonProperty("market_type") String marketType,
@JsonProperty("country_codes") List<String> countryCodes
) {
return new MarketFilter(marketType,
countryCodes != null ? List.copyOf(countryCodes) : List.of());
}
}
The factory method runs on deserialisation, giving you a place to apply defensive copies and normalisation.
A Lombok @Value class is a natural record equivalent:
// Before
@Value
public class OrderDto {
String id;
String side;
double price;
double size;
}
// After
public record OrderDto(String id, String side, double price, double size) {}
The serialisation behaviour is identical. The accessor names change from getId() to id() — check any code that calls them directly. Jackson handles both transparently.
Records are now the right default for DTOs in any project on Java 16 or later. They communicate intent clearly — this type exists to carry data — and eliminate the friction that makes developers reach for maps or raw JSON nodes when they should be defining a proper type.
If you’re modernising a Spring Boot codebase and want a review of your DTO design, get in touch.