Java domain modeling has historically been verbose and error-prone. Entity classes with dozens of getter/setter pairs, boolean fields that encode state (isActive, isCancelled, isPending), and null fields used to represent the absence of a value all create code that’s hard to understand and easy to misuse.
Records and sealed classes address this. They’re not cosmetic improvements — they change what kinds of mistakes the compiler can catch for you.
Records: Immutable Data Carriers
A record is a class defined by its components. The compiler generates the constructor, accessors, equals, hashCode, and toString automatically:
public record Money(BigDecimal amount, Currency currency) {
// Compact constructor for validation
public Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException("Too many decimal places for " + currency);
}
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(currency, other.currency);
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(BigDecimal factor) {
return new Money(amount.multiply(factor).setScale(
currency.getDefaultFractionDigits(), HALF_UP), currency);
}
}
Compare to the equivalent Java bean:
public class Money {
private BigDecimal amount;
private Currency currency;
public Money() {} // For frameworks
public Money(BigDecimal amount, Currency currency) {
// Is the validation here? In a factory? Not at all?
this.amount = amount;
this.currency = currency;
}
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public Currency getCurrency() { return currency; }
public void setCurrency(Currency currency) { this.currency = currency; }
// equals, hashCode, toString — hopefully generated, possibly wrong
}
The record version is shorter, immutable by default, and validation is guaranteed to run on every construction. The bean version is mutable — setAmount(null) is allowed, and there’s no way to know if the object is in a valid state.
Records as Value Objects
Records are the natural implementation of domain value objects — types that are equal by value, not identity.
// Without records — boilerplate-heavy value objects
public final class OrderId {
private final String value;
public OrderId(String value) {
this.value = Objects.requireNonNull(value);
if (!value.matches("ORD-[0-9]+")) throw new IllegalArgumentException();
}
public String value() { return value; }
@Override public boolean equals(Object o) { ... }
@Override public int hashCode() { ... }
@Override public String toString() { return value; }
}
// With records — same behavior, one line
public record OrderId(String value) {
public OrderId {
Objects.requireNonNull(value);
if (!value.matches("ORD-[0-9]+")) throw new IllegalArgumentException();
}
}
Typed IDs prevent a common class of bugs where IDs of different types are mixed up:
// Without typed IDs — which ID is which?
void processOrder(String orderId, String customerId, String productId) { ... }
// With typed IDs — compiler catches argument transposition
void processOrder(OrderId orderId, CustomerId customerId, ProductId productId) { ... }
Sealed Classes: Encoding Valid States
The boolean encoding problem: a domain object with multiple boolean fields often represents states that can’t all exist simultaneously.
// Boolean encoding of states — what does isActive=true, isCancelled=true mean?
public class Order {
private boolean isPending;
private boolean isActive;
private boolean isCancelled;
private boolean isDelivered;
// At most one should be true — but the type doesn't enforce this
}
Sealed classes encode the valid states as types:
public sealed interface OrderStatus
permits OrderStatus.Pending, OrderStatus.Active,
OrderStatus.Cancelled, OrderStatus.Delivered {
record Pending(Instant createdAt) implements OrderStatus {}
record Active(Instant confirmedAt, String trackingId) implements OrderStatus {}
record Cancelled(Instant cancelledAt, CancellationReason reason) implements OrderStatus {}
record Delivered(Instant deliveredAt, String signedBy) implements OrderStatus {}
}
Each state carries its own relevant data. Pending doesn’t have a trackingId — that concept doesn’t exist for a pending order. Active does. The type system encodes this.
Usage:
public Order {
private final OrderId id;
private final CustomerId customerId;
private OrderStatus status; // One of the sealed subtypes
public boolean canBeShipped() {
return status instanceof OrderStatus.Active;
}
public Optional<String> trackingId() {
return switch (status) {
case OrderStatus.Active a -> Optional.of(a.trackingId());
case OrderStatus.Delivered d -> Optional.of(d.trackingId()); // Stored separately
default -> Optional.empty();
};
}
}
Modeling Results and Errors
The null return problem: Java methods that return null for “not found” or “failed” require callers to remember to check for null. Forget once and you get an NPE at runtime.
Records with sealed interfaces model this explicitly:
public sealed interface Result<T>
permits Result.Success, Result.Failure {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String message, Throwable cause) implements Result<T> {
public Failure(String message) { this(message, null); }
}
static <T> Result<T> success(T value) { return new Success<>(value); }
static <T> Result<T> failure(String message) { return new Failure<>(message); }
static <T> Result<T> failure(String message, Throwable cause) {
return new Failure<>(message, cause);
}
}
The caller is forced to handle both cases:
Result<User> result = userService.findById(id);
String display = switch (result) {
case Result.Success<User>(User u) -> "Found: " + u.name();
case Result.Failure<User>(var msg, _) -> "Error: " + msg;
};
Compare to Optional, which only models presence/absence and doesn’t carry error information, or the null return pattern, which isn’t enforced by the type system.
Replacing Builder Patterns
Records with well-designed constructors often eliminate the need for builders:
// Traditional builder pattern for a complex value object
User user = User.builder()
.id(userId)
.email(email)
.role(UserRole.ADMIN)
.createdAt(Instant.now())
.build();
// Record with named parameters (Java 21 previews; for now, use factory methods)
record User(UserId id, String email, UserRole role, Instant createdAt) {
public static User createAdmin(UserId id, String email) {
return new User(id, email, UserRole.ADMIN, Instant.now());
}
public static User createRegular(UserId id, String email) {
return new User(id, email, UserRole.USER, Instant.now());
}
}
Factory methods on the record serve the same purpose as builders for complex construction.
What Records Are Not
Records are not JPA entities. JPA requires mutable state (or complex workarounds), a no-arg constructor, and entity identity semantics that don’t fit the record model. Use records for your domain model; use separate JPA entities for persistence and map between them.
Records are not for everything. Mutable state, complex inheritance hierarchies, framework-managed objects — records don’t fit these. Use them for data that benefits from value semantics and immutability.
The Design Shift
The shift from Java beans to records + sealed classes is a shift in where the design thinking goes.
With Java beans, the type system provides minimal constraints. The design lives in documentation, conventions, and runtime validation. Objects can be in invalid states. Methods can receive wrong inputs.
With records and sealed classes, valid states are encoded in the type hierarchy. The compiler enforces them. Invalid states literally don’t exist as types.
This doesn’t eliminate all bugs. But it changes the character of the bugs that remain — from “this object is in an invalid state at runtime” to “this code doesn’t compile because it’s missing a case.” Compile-time errors are cheaper to fix than runtime errors in production.