Using Java Records for Immutable Data Transfer Objects: Design, Boundaries, and Checks
Java records reduce boilerplate for immutable data carriers, but their validation does not run during deserialization, requiring explicit trust boundaries and operational checks.
13 Jul 2026, 17:42 UTC

Problem
When exchanging data between services or modules you need a carrier that is immutable, structurally equal, and lightweight. Writing a plain Java class with final fields, a constructor, getters, equals, hashCode and toString repeats boilerplate and increases the chance of mismatched implementations.
Takeaway
Java records (since JDK 17) generate the canonical constructor, accessors, equals, hashCode and toString automatically, giving you a transparent data carrier. However, compact‑constructor validation does not run during deserialization, so you must add explicit checks after reading untrusted data.
Requirements
- Immutable data holder with value‑based equality.
- Ability to validate or normalize fields at construction time.
- Clear trust boundary: the record may cross process or network boundaries where untrusted data can arrive.
- Operational checks to detect invariant violations after deserialization.
- Minimal build impact – no annotation processing or bytecode generation.
Smallest Suitable Design
The simplest record that satisfies the requirements is:
public record OrderEvent(long orderId,
BigDecimal amount,
String customerId) {
public OrderEvent {
if (orderId <= 0) {
throw new IllegalArgumentException("orderId must be positive");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId must not be blank");
}
}
}
The compact constructor enforces the invariants; the record is final and implicitly extends java.lang.Record, so it cannot be subclassed.
Trust / Data Boundaries
When an OrderEvent crosses a trust boundary (e.g., sent over JMS, written to a file, or received via HTTP), the receiving side must treat the incoming bytes as potentially untrusted. The record’s accessors are public by name, so any field can be read, but the compact constructor that performed validation is not invoked during deserialization.
Operational Checks
To guarantee invariants after deserialization, add a validation step:
public static OrderEvent safeDeserialize(byte[] data) throws IOException, ClassNotFoundException {
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
Object obj = ois.readObject();
if (!(obj instanceof OrderEvent)) {
throw new InvalidObjectException("Not an OrderEvent");
}
OrderEvent re = (OrderEvent) obj;
// Re‑apply compact‑constructor validation
if (re.orderId() <= 0) {
throw new InvalidObjectException("orderId must be positive");
}
if (re.amount() == null || re.amount().compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidObjectException("amount must be positive");
}
if (re.customerId() == null || re.customerId().isBlank()) {
throw new InvalidObjectException("customerId must not be blank");
}
return re;
}
}
Place this method in a utility class or as a static factory on the record itself.
Verification Steps
- Compile with a JDK 17+ release:
javac --release 17 OrderEvent.java. - Inspect the generated bytecode to confirm the canonical constructor and accessors:
javap -p OrderEvent.class. Expected output includespublic long orderId(),public java.math.BigDecimal amount(),public java.lang.String customerId(),public boolean equals(java.lang.Object),public int hashCode(), andpublic java.lang.String toString(). - Write a unit test that passes invalid values to the compact constructor and asserts an
IllegalArgumentExceptionis thrown. - Serialize a valid OrderEvent, tamper with the byte stream (e.g., set amount to zero), deserialize using
ObjectInputStreamdirectly, and confirm that no exception is thrown. Then run the same data throughsafeDeserializeand verify that anInvalidObjectExceptionis raised. - In CI, check the JDK version:
java -versionensures the records feature is stable and not a preview.
Limitations
- Record components are public by name; they cannot be used to hide secrets or mutable state.
- Compact‑constructor validation is bypassed during deserialization, requiring the explicit check shown above.
- Records are final and implicitly extend java.lang.Record, so they cannot be subclassed or used where polymorphic behavior is needed.
- Changing a published record to a regular class is source‑breaking for clients that deconstruct components or rely on the canonical accessors.
When to Change the Design
- If you need mutable state after construction (e.g., an entity with identity that changes over time), replace the record with a regular class.
- If you require inheritance or want to extend a base abstract type, a regular class is necessary.
- If you must hide certain components from callers (e.g., security‑sensitive fields), use a class with private fields and controlled accessors.
- If you need custom serialization logic that depends on the constructor (e.g., to enforce invariants during deserialization), consider a custom
writeObject/readObjectimplementation or a serialization proxy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.