Java Records and Sealed Classes: 10 Ways to Build Cleaner, Safer Domain Models
Model Java domains cleanly with records and sealed classes. Learn 10 practical patterns for immutable, type-safe, expressive code. See real examples now.
I remember the first time I tried to model a business domain in Java. I ended up with dozens of classes, each with constructors, getters, equals, hashCode, and toString. The code worked, but every change felt like wading through mud. Then I discovered records and sealed classes. Together, they transformed how I write Java. Let me walk you through ten ways I use them to build clean, safe, and expressive domain models. I’ll keep it simple and show you the code I actually write.
A record is the closest Java gets to a plain value container without ceremony. You define the components, and the compiler generates everything else. I use records for any object that is identified by its data alone. For example, a user identifier is just a string with special meaning.
public record UserId(String value) {
public UserId {
Objects.requireNonNull(value);
}
}
The compact constructor runs before the object is created. I can validate or normalize the input there. This pattern protects my system from invalid data from the start. If you pass a null to UserId, you get an exception immediately, not a mysterious failure hours later. Records are final, so nobody can subclass them. That is fine because value objects should never be extended.
I also use records for composite values like addresses.
public record Address(String street, String city, String postalCode) {}
No boilerplate. I get proper equals and hashCode for free. This makes records perfect for keys in maps or sets. When I see a record in my codebase, I know it is immutable and safe to share.
Now, what happens when I have a type that can be one of several alternatives? For example, an order status. It can be pending, shipped, delivered, or cancelled. Sealed interfaces let me tell the compiler exactly which classes are allowed to implement the interface.
public sealed interface OrderStatus permits Pending, Shipped, Delivered, Cancelled {}
public record Pending(LocalDateTime createdAt) implements OrderStatus {}
public record Shipped(LocalDateTime shippedAt, String trackingNumber) implements OrderStatus {}
public record Delivered(LocalDateTime deliveredAt) implements OrderStatus {}
public record Cancelled(LocalDateTime cancelledAt, String reason) implements OrderStatus {}
The permits clause is like a guest list. Only the named types can appear. If someone tries to create an Expired class and make it implement OrderStatus, the compiler refuses. This restriction might feel heavy at first, but it makes your system closed for extension in the right way. You know exactly what possibilities exist, and you can handle them all.
The real power comes when you combine sealed types with pattern matching in switch expressions. The compiler forces you to cover every subtype.
public String describe(OrderStatus status) {
return switch (status) {
case Pending p -> "Order placed at " + p.createdAt();
case Shipped s -> "Shipped on " + s.shippedAt() + " with tracking " + s.trackingNumber();
case Delivered d -> "Delivered on " + d.deliveredAt();
case Cancelled c -> "Cancelled: " + c.reason();
};
}
Notice there is no default case. I do not need one because the switch is exhaustive. If I later add a new subtype like RefundRequested, the compiler points to every switch statement that handles OrderStatus. I can fix them before my code even compiles. This eliminates a whole category of runtime errors where a developer forgets to add a new branch.
One question I often hear is: what if my records share common fields? Records cannot extend other records, but they can extend abstract classes. I use this pattern when I want a shared method or field across a sealed hierarchy.
public sealed abstract class Payment {
private final BigDecimal amount;
public Payment(BigDecimal amount) { this.amount = amount; }
public BigDecimal amount() { return amount; }
public abstract String authorizationCode();
}
public record CreditCardPayment(BigDecimal amount, String cardLastFour, String authCode) extends Payment {
public String authorizationCode() { return authCode; }
}
public record BankTransferPayment(BigDecimal amount, String reference) extends Payment {
public String authorizationCode() { return reference; }
}
The abstract class holds the common field amount and defines an abstract method. Each record provides its own implementation. I also get the automatic constructor, equals, and toString from records for the subtype-specific fields. This hybrid approach works well when you need both shared behavior and the convenience of records.
Validation is a recurring need in domain models. Records give you a clean place to put it: the compact constructor.
public record EmailAddress(String value) {
public EmailAddress {
if (value == null || !value.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
value = value.toLowerCase(); // Normalize
}
}
I write validation logic directly in the constructor. The assignment to the field value happens automatically after the constructor body runs. I can modify the parameter (like converting to lowercase) and the final field gets that value. This ensures that every EmailAddress instance I create is valid. There is no separate factory method to forget to call. Whenever I receive an email from a client or database, it goes through the same validation path.
Records and sealed classes together let me define algebraic data types. This is a fancy term for a type that is either one thing or another. A common example is a result type for operations that may succeed or fail.
public sealed interface Result<T> permits Success, Failure {}
public record Success<T>(T value) implements Result<T> {}
public record Failure<T>(String error, int code) implements Result<T> {}
When I return a Result<T> from a method, the caller must handle both cases. Pattern matching makes that explicit.
Result<User> userResult = findUser(id);
String displayName = switch (userResult) {
case Success(var user) -> user.name();
case Failure(var error, var code) -> "Error: " + error;
};
I no longer rely on exceptions for expected failures. The type system itself documents that this operation can fail. And the compiler forces me to think about the failure case every time I call the method.
Domain events are another place where records shine. Each event is an immutable description of something that happened in the past. I model them as records implementing a sealed interface.
public sealed interface DomainEvent permits OrderPlaced, OrderShipped, PaymentReceived {}
public record OrderPlaced(String orderId, String customerId, BigDecimal total,
LocalDateTime occurredAt) implements DomainEvent {}
public record OrderShipped(String orderId, String trackingNumber,
LocalDateTime shippedAt) implements DomainEvent {}
public record PaymentReceived(String orderId, BigDecimal amount,
LocalDateTime paidAt) implements DomainEvent {}
The sealed interface groups all possible events. When I build an event store or a publisher, I can write a single switch over DomainEvent that handles every known event. If I add a new event type, every switch that processes events will fail to compile until I update it. This makes event-driven architecture safer to evolve.
Sometimes a sealed hierarchy is only useful within a single class. I declare it as a private inner hierarchy.
public class WorkflowEngine {
private sealed interface WorkflowState permits Init, Processing, Completed, Failed {}
private record Init() implements WorkflowState {}
private record Processing(String currentStep) implements WorkflowState {}
private record Completed(String result) implements WorkflowState {}
private record Failed(String error) implements WorkflowState {}
public WorkflowState transition(WorkflowState current, Event event) {
return switch (current) {
case Init i -> new Processing("start");
case Processing p -> handleProcessing(p, event);
case Completed c -> throw new IllegalStateException("Already completed");
case Failed f -> f; // Stay in failed state
};
}
}
No one outside WorkflowEngine can see or extend these state types. The state machine remains closed and predictable. I often use this pattern for internal processing pipelines or state machines that should not leak into the rest of the system.
Guards in pattern matching let me add conditions to specific cases. I use this when the simple type match is not enough.
public String describe(OrderStatus status) {
return switch (status) {
case Pending p when p.createdAt().isBefore(LocalDateTime.now().minusHours(24))
-> "Overdue pending order";
case Pending p -> "Recent pending order";
case Shipped s -> "Shipped";
case Delivered d -> "Delivered";
case Cancelled c when c.reason().equals("timeout")
-> "Automatically cancelled";
case Cancelled c -> "Manually cancelled";
};
}
Guards are evaluated after the type match. They allow me to differentiate based on instance data without needing nested if statements. The compiler still checks that all sealed subtypes are covered, so I cannot accidentally omit a branch.
Finally, I use sealed records to replace legacy enums that carry instance-specific data. Old Java enums have a fixed set of constants, but each constant shares the same fields. If you need different fields per value, you end up with ugly workarounds.
// Legacy enum with separate fields
public enum ErrorCode {
INVALID_INPUT("Invalid input"), TIMEOUT("Timeout"), UNKNOWN("Unknown");
private final String message;
ErrorCode(String msg) { this.message = msg; }
public String message() { return message; }
}
With sealed records, each alternative becomes a full-fledged type.
public sealed interface ErrorCode permits InvalidInput, Timeout, Unknown {}
public record InvalidInput(String fieldName, String rejectedValue) implements ErrorCode {}
public record Timeout(Duration duration) implements ErrorCode {}
public record Unknown(String detail) implements ErrorCode {}
Now InvalidInput carries the specific field name and rejected value, while Timeout carries a duration. The switch on ErrorCode is exhaustive, and adding a new error type does not require modifying existing enum constants. This migration has saved me countless hours of maintenance.
Over time, I have learned that records and sealed classes are not just syntactic sugar. They are tools for expressing intent. When I write a record, I tell my future self and my colleagues: this data is immutable and identified by its content. When I write a sealed interface, I guarantee that the set of possibilities is fixed and known. The combination makes my domain models self-documenting and resistant to mistakes.
Start small. Replace a simple data holder class with a record. Convert an enum that has separate fields into a sealed hierarchy. Pattern match on the result. The compiler will guide you. You will find that your code becomes shorter, clearer, and safer. And you will wonder how you ever lived without them.