Most REST controllers do not need mutable request beans with setters. On Spring Boot 3 and Java 17+, a record can describe the JSON contract in one line while still working with Jackson and Bean Validation.
The important design choice is bigger than saving boilerplate: records belong at the API boundary. Let the controller accept and return transport-shaped records, then map those values to the model your application owns.
This is the Spring Boot continuation of Java Records: Immutable Data Without the Boilerplate. We will start with that boundary architecture, then build a small POST + GET API.
The boundary we are building
A clean request path has an explicit handoff between HTTP concerns and application concerns:
JSON request
|
v
Jackson -> CreateOrderRequest record
|
@Valid
|
v
@RestController
|
map to command
|
v
OrderService -> service-owned Order
|
map to response
|
v
Jackson <- OrderResponse record <- JSON response
Each layer gets one job:
- Jackson translates JSON to and from Java values.
- Bean Validation rejects malformed client input before business logic runs.
- The controller owns HTTP status codes and boundary mapping.
- The service owns the use case and its internal model.
- Records make the external request and response contract visible.
That last point matters. If a database entity leaks directly into a response, a lazy relationship, renamed column field, or persistence annotation can silently change the public API. An explicit response record prevents that coupling.
Why records fit REST DTOs
REST DTOs are usually transparent data carriers. Records provide exactly that shape:
public record OrderResponse(
UUID id,
String customerId,
String status
) {}
The compiler supplies the canonical constructor, component accessors, equals, hashCode, and toString. Components are final, so there is no half-populated DTO assembled through setters.
Records are only shallowly immutable. A final List component can still point to a mutable list. Copy collections in trusted constructors or while mapping a response when the DTO may outlive the current call.
Jackson uses record components
Spring Boot 3’s JSON support understands records. Given this DTO:
public record CustomerResponse(String name, String email) {}
Jackson serializes it as the expected object:
{
"name": "Ada",
"email": "ada@example.com"
}
The Java accessors are name() and email(), not getName() and getEmail():
CustomerResponse customer = new CustomerResponse("Ada", "ada@example.com");
customer.name(); // correct
customer.getName(); // does not compile
Jackson binds JSON property names to record component names and invokes the canonical constructor. You do not need JavaBean getters, setters, or a no-argument constructor for these DTOs.
Note: If an older library insists on getName(), that library is expecting JavaBean conventions. Add an adapter at that integration boundary instead of adding fake getters to every record.
Validation has two different jobs
Bean Validation and compact constructors are complementary, but they report failures differently.
Use @Valid for client input
Put Jakarta Validation constraints on request record components:
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty List<@Valid LineItemRequest> items
) {}
public record LineItemRequest(
@NotBlank String sku,
@Positive int quantity
) {}
Then add @Valid to the controller parameter:
@PostMapping
ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request
) {
// ...
}
Spring validates the fully constructed request and returns 400 Bad Request when a constraint fails. This is the best default for errors a caller can fix.
Use compact constructors for invariants
A compact constructor can reject impossible values or make defensive copies:
public record OrderResponse(
UUID id,
String customerId,
List<LineItemResponse> items,
String status
) {
public OrderResponse {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(customerId, "customerId");
items = List.copyOf(items);
Objects.requireNonNull(status, "status");
}
}
This guarantees that every OrderResponse created anywhere in the application satisfies those invariants.
Do not use a compact constructor as a drop-in replacement for request validation. Jackson invokes it while deserializing. An exception there is a construction failure and will not automatically produce the same field-by-field 400 response as Bean Validation. Use constraints for client mistakes; use constructors for invariants that must hold regardless of who creates the record.
Working lab: orders API
The lab exposes:
POST /api/ordersto create an in-memory order.GET /api/orders/{id}to fetch it.
It uses Java 17, Spring Boot 3, Spring MVC, and Jakarta Validation. The in-memory service keeps the focus on the HTTP boundary; a later persistence layer can replace it without changing the JSON contract.
Dependencies
Generate a Boot 3 project with Java 17+, then include the web and validation starters:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
Controller and DTO records
Keep the transport records beside the controller for this small lab. In a larger API, give them separate files under an api package.
package com.geekmonks.orders;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orders;
public OrderController(OrderService orders) {
this.orders = orders;
}
@PostMapping
ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request) {
var command = new OrderService.CreateOrder(
request.customerId(),
request.items().stream()
.map(item -> new OrderService.Line(
item.sku(), item.quantity()))
.toList());
var order = orders.create(command);
var response = toResponse(order);
return ResponseEntity
.created(URI.create("/api/orders/" + response.id()))
.body(response);
}
@GetMapping("/{id}")
OrderResponse findById(@PathVariable UUID id) {
return toResponse(orders.findById(id));
}
private static OrderResponse toResponse(OrderService.Order order) {
return new OrderResponse(
order.id(),
order.customerId(),
order.lines().stream()
.map(line -> new LineItemResponse(
line.sku(), line.quantity()))
.toList(),
order.status().name());
}
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty List<@Valid LineItemRequest> items
) {}
public record LineItemRequest(
@NotBlank String sku,
@Positive int quantity
) {}
public record OrderResponse(
UUID id,
String customerId,
List<LineItemResponse> items,
String status
) {
public OrderResponse {
items = List.copyOf(items);
}
}
public record LineItemResponse(String sku, int quantity) {}
}
Notice the two explicit mapping points:
CreateOrderRequestbecomesOrderService.CreateOrder.OrderService.OrderbecomesOrderResponse.
The repeated fields are intentional. Mapping is the seam that lets the HTTP contract and internal model evolve independently.
In-memory service
The service owns its command and result types. They are records too because this lab’s internal values are immutable carriers, but the controller does not return them directly.
package com.geekmonks.orders;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@Service
public class OrderService {
private final Map<UUID, Order> orders = new ConcurrentHashMap<>();
public Order create(CreateOrder command) {
var order = new Order(
UUID.randomUUID(),
command.customerId(),
List.copyOf(command.lines()),
OrderStatus.CREATED);
orders.put(order.id(), order);
return order;
}
public Order findById(UUID id) {
var order = orders.get(id);
if (order == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "Order not found");
}
return order;
}
public record CreateOrder(String customerId, List<Line> lines) {}
public record Line(String sku, int quantity) {}
public record Order(
UUID id,
String customerId,
List<Line> lines,
OrderStatus status
) {}
public enum OrderStatus {
CREATED
}
}
In production, avoid throwing web-specific exceptions from the service. Translate a domain OrderNotFoundException to 404 in @RestControllerAdvice. The shortcut above keeps this two-file lab small.
Run the API
Start the application:
./mvnw spring-boot:run
Create an order:
curl -i -X POST http://localhost:8080/api/orders \
-H 'Content-Type: application/json' \
-d '{
"customerId": "customer-42",
"items": [
{"sku": "java-records", "quantity": 2}
]
}'
The response is 201 Created, includes a Location header, and has a JSON body similar to:
HTTP/1.1 201
Location: /api/orders/7da2aab5-aeba-46f3-9610-39f0aa46098a
Content-Type: application/json
{"id":"7da2aab5-aeba-46f3-9610-39f0aa46098a","customerId":"customer-42","items":[{"sku":"java-records","quantity":2}],"status":"CREATED"}
Fetch that order using the returned id:
curl -sS \
http://localhost:8080/api/orders/7da2aab5-aeba-46f3-9610-39f0aa46098a
Now send invalid input:
curl -i -X POST http://localhost:8080/api/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":"","items":[{"sku":"book","quantity":0}]}'
Spring returns 400 Bad Request because customerId is blank and quantity is not positive. The exact error body depends on your Boot version and any ProblemDetail or exception-handler customization. When @Valid Fails, the Body Should Help owns that contract.
Keep persistence behind the boundary
When JPA arrives, keep this controller contract:
CreateOrderRequest record
|
v
application command -> JPA entity -> repository
|
v
OrderResponse record
Do not annotate OrderResponse with @Entity, and do not return an entity directly from @RestController. JPA entities have persistence identity, lifecycle transitions, lazy relationships, and proxy constraints. API records have value semantics and a fixed serialized shape. Those are different jobs.
This separation also gives you one obvious place to decide:
- whether a lazy association should be loaded,
- which fields are safe to expose,
- how an enum appears on the wire,
- whether internal ids become public ids,
- and whether nested data should be embedded or linked.
The extra mapper is cheaper than discovering those decisions accidentally during JSON serialization.
Design checklist
Request JSON -> @RequestBody record -> @Valid
Controller -> maps request DTO to command
Service -> owns use case and internal model
Controller -> maps result to response record
Response -> Jackson serializes record components
Accessor: customerId(), not getCustomerId()
Client errors: Jakarta Validation constraints
Always-true invariants: compact constructor
Mutable components: defensive copy
Persistence entities: never your public REST contract
Wrap-up
Spring Boot 3 and Java 17+ make records a natural default for REST request and response DTOs. Jackson understands their canonical constructors and component accessors, while @Valid applies Jakarta Validation constraints without setters or no-argument constructors.
Keep the boundary explicit: deserialize into a request record, validate it, map it to an application command, and map the result back to a response record. That small amount of mapping protects the API when persistence and domain models become more complicated.