Implementing Immutable DTOs with Java Records for Cross-Service Exchange
Learn how to use Java Records as minimal, immutable DTOs for cross-service communication, including strategies for validation, defensive copying, and avoiding binary compatibility issues.
24 Jul 2025, 23:24 UTC

The Problem: State Leakage in Data Transfer Objects
When passing data between services or across trust boundaries, mutable Data Transfer Objects (DTOs) introduce risk. A shared object that can be modified after instantiation leads to unpredictable side effects, making debugging difficult and concurrency dangerous. While traditional POJOs (Plain Old Java Objects) can be made immutable through final fields and the removal of setters, the boilerplate required for equals(), hashCode(), and toString() often leads to developer omission or maintenance errors.
The takeaway: Java Records (introduced in Java 14/16) provide the smallest suitable design for immutable DTOs by treating data as a transparent carrier, ensuring that the object's state is fixed at construction.
The Minimal Design for API Exchange
For cross-service exchange, the design goal is a minimal API surface. A record should contain only the domain fields necessary for the transaction, residing in a shared API module. Because records are implicitly final and their components are final, they prevent inheritance and accidental state mutation.
// Defined in a shared API module
public record UserProfileRequest(
String username,
String email,
int age
) {}
This design eliminates the need for manual getter methods, as records provide accessor methods that match the component names (e.g., username() instead of getUsername()). By avoiding additional logic or inheritance, the API remains stable and easy to serialize across network boundaries.
Trust Boundaries and Data Validation
Records are value-based, but they do not automatically validate the data they carry. When a record crosses a trust boundary—such as moving from an untrusted JSON request to an internal business service—you must ensure the components meet domain constraints.
The most efficient way to handle this is via the compact constructor. This allows you to validate data before the record is fully instantiated without repeating the assignment of fields.
public record UserProfileRequest(
String username,
String email,
int age
) {
public UserProfileRequest {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username cannot be empty");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Invalid age range");
}
// No need to write this.username = username; it happens automatically
}
}
Failure Modes and Immutability Gaps
A common misconception is that records are deeply immutable. If a record component is a mutable object, such as a List or an array, the record's state can still be changed from the outside.
The Mutable Component Risk
Consider a record containing a list: public record Order(List<String> items) {}. If a caller passes a ArrayList into the constructor, they retain a reference to that list and can modify its contents, breaking the immutability of the Order record.
Mitigation: Use defensive copying or immutable wrappers within the compact constructor:
public record Order(List<String> items) {
public Order {
items = List.copyOf(items); // Creates an unmodifiable copy
}
}
Binary Compatibility Failures
Records are sensitive to schema changes. Adding a new component to a record changes its canonical constructor. If a producer updates the record definition but a consumer is using an older version of the compiled class, the consumer will encounter a NoSuchMethodError or InvalidClassException at runtime. To avoid this, version your API modules or use a serialization format (like JSON) that handles missing fields gracefully.
Operational Checks and Verification
To verify the implementation, perform the following checks in your development environment:
- Immutability Check: Instantiate a record with a
List. Attempt to call.add()on that list. If the record was implemented withList.copyOf(), it should throw anUnsupportedOperationException. - Validation Check: Pass a
nullor out-of-range value to the constructor and verify theIllegalArgumentExceptionis thrown before the object is created. - Serialization Check: Serialize the record to JSON and back. Ensure that the resulting record is equal (via
.equals()) to the original.
When to Abandon Records
Records are not a universal replacement for classes. You should move back to traditional classes if:
- Incremental Updates: You need a DTO that supports partial updates (e.g., a form where only one field is changed at a time) and you prefer mutation over creating new object instances.
- Inheritance: Your domain requires a complex class hierarchy. Records cannot extend other classes and cannot be extended.
- Extreme Performance: In high-throughput scenarios where the allocation of many short-lived record objects creates GC pressure, primitive arrays or specialized mutable buffers may be necessary.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.