Java Records and Sealed Classes for Domain Modeling
Use Java Records and Sealed Classes to build expressive domain models. Covers compact constructors, sealed hierarchies, pattern matching, and real-world examples.
What you'll learn
- ✓How Records eliminate boilerplate for immutable data
- ✓How Sealed Classes create closed type hierarchies
- ✓How to combine both for algebraic data types in Java
Prerequisites
- •Java OOP fundamentals (classes, inheritance)
- •Basic understanding of generics
- •Java 17+
Records (Java 16) and Sealed Classes (Java 17) solve two distinct problems that become powerful when combined. Records remove the ceremony of writing data-carrier classes. Sealed Classes restrict which types can extend a given class. Together they let you model domains with the precision of algebraic data types found in languages like Kotlin, Scala, or Rust.
Records: immutable data without boilerplate
A record is a special kind of class designed to hold data. The compiler generates the constructor, accessors, equals, hashCode, and toString from the record components you declare.
// one line replaces ~40 lines of boilerplate
public record Money(BigDecimal amount, Currency currency) {}
// usage
Money price = new Money(new BigDecimal("29.99"), Currency.getInstance("USD"));
System.out.println(price.amount()); // 29.99
System.out.println(price.currency()); // USD
System.out.println(price); // Money[amount=29.99, currency=USD]
Records are implicitly final, their fields are private final, and they extend java.lang.Record. You cannot add mutable state.
Compact constructors for validation
The compact constructor lets you validate or normalize data without repeating parameter assignments:
public record EmailAddress(String value) {
public EmailAddress {
// compact constructor: parameters are implicit
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
value = value.toLowerCase().strip();
// implicit assignment: this.value = value
}
}
EmailAddress email = new EmailAddress("USER@Example.COM ");
System.out.println(email.value()); // user@example.com
Custom methods and static factories
Records can have instance methods, static methods, and implement interfaces:
public record DateRange(LocalDate start, LocalDate end) implements Comparable<DateRange> {
public DateRange {
if (end.isBefore(start)) {
throw new IllegalArgumentException("end must be after start");
}
}
// static factory
public static DateRange ofDays(LocalDate start, int days) {
return new DateRange(start, start.plusDays(days));
}
// instance method
public long days() {
return ChronoUnit.DAYS.between(start, end);
}
// interface implementation
@Override
public int compareTo(DateRange other) {
return this.start.compareTo(other.start);
}
}
What records cannot do
Records have deliberate limitations:
- They cannot extend other classes (they implicitly extend
Record). - Their components are always
final(no mutable fields). - You cannot declare additional instance fields.
- They are implicitly
final(no subclassing).
These constraints exist by design. If you need mutable state or inheritance, use a regular class.
Sealed Classes: controlling your type hierarchy
A sealed class or interface declares exactly which classes can extend or implement it. The compiler enforces this, making your type hierarchy closed.
public sealed interface Shape
permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double a, double b, double c) implements Shape {}
// this would NOT compile:
// public record Pentagon(double side) implements Shape {} // not in permits
The permits clause
Permitted subtypes must be in the same package (or same module). Each permitted subtype must be one of:
final(no further subclassing)sealed(further restricted subclassing)non-sealed(open for extension)
public sealed interface Payment permits CardPayment, BankTransfer, WalletPayment {}
// final: no one can extend CardPayment
public record CardPayment(String cardNumber, Money amount) implements Payment {}
// sealed: only specific subtypes allowed
public sealed interface BankTransfer extends Payment
permits DomesticTransfer, InternationalTransfer {}
public record DomesticTransfer(String routingNumber, Money amount) implements BankTransfer {}
public record InternationalTransfer(String swift, Money amount) implements BankTransfer {}
// non-sealed: anyone can extend WalletPayment
public non-sealed abstract class WalletPayment implements Payment {}
Combining Records and Sealed Classes
The real power emerges when you use records as the leaves of sealed hierarchies. This gives you algebraic data types in Java.
Example: modeling an order result
public sealed interface OrderResult {
record Success(Order order, String confirmationId) implements OrderResult {}
record OutOfStock(String productId, int requested, int available) implements OrderResult {}
record PaymentFailed(String reason, String transactionId) implements OrderResult {}
record ValidationError(List<String> errors) implements OrderResult {}
}
Each variant carries exactly the data it needs. The hierarchy is closed, so you can handle every case exhaustively.
Pattern matching with switch
Java 21 enables pattern matching in switch expressions, which pairs perfectly with sealed hierarchies:
public String describeResult(OrderResult result) {
return switch (result) {
case OrderResult.Success s ->
"Order confirmed: " + s.confirmationId();
case OrderResult.OutOfStock oos ->
"Only " + oos.available() + " of " + oos.productId() + " available";
case OrderResult.PaymentFailed pf ->
"Payment failed: " + pf.reason() + " (txn: " + pf.transactionId() + ")";
case OrderResult.ValidationError ve ->
"Validation errors: " + String.join(", ", ve.errors());
};
// no default needed: compiler knows all cases are covered
}
Because OrderResult is sealed, the compiler verifies exhaustiveness. If you add a new variant, every switch that handles OrderResult will produce a compilation error until updated.
Example: building a simple expression evaluator
public sealed interface Expr {
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr operand) implements Expr {}
}
public static double evaluate(Expr expr) {
return switch (expr) {
case Expr.Num n -> n.value();
case Expr.Add a -> evaluate(a.left()) + evaluate(a.right());
case Expr.Mul m -> evaluate(m.left()) * evaluate(m.right());
case Expr.Neg n -> -evaluate(n.operand());
};
}
// usage
Expr expression = new Expr.Add(
new Expr.Num(3),
new Expr.Mul(new Expr.Num(2), new Expr.Num(5))
);
System.out.println(evaluate(expression)); // 13.0
Records vs Lombok vs regular classes
| Feature | Record | Lombok @Value | Regular Class |
|---|---|---|---|
| Boilerplate | None | Annotation | Full |
| Immutability | Enforced | Convention | Manual |
| Inheritance | Cannot extend | Can extend | Can extend |
| Extra fields | Not allowed | Allowed | Allowed |
| Compile-time | Built-in | Annotation processor | N/A |
| Serialization | Works naturally | Needs config | Manual |
Use records when your type is a transparent carrier of immutable data. Use Lombok or regular classes when you need mutable state, inheritance, or encapsulation beyond what records provide.
Practical guidelines
- Start with records for value objects:
Money,EmailAddress,DateRange,Coordinateare ideal candidates. - Use sealed interfaces for result types: Replace exception-based error handling with sealed result hierarchies.
- Nest records inside sealed interfaces: Keep related types together and reduce file count.
- Let the compiler enforce exhaustiveness: Avoid
defaultin switch expressions over sealed types so the compiler catches missing cases. - Validate in compact constructors: Make illegal states unrepresentable by validating in the constructor.
Records and sealed classes together move Java closer to type-driven development. Define your domain with precise types, let the compiler catch mistakes, and write code that is both concise and safe.
Related articles
- Java Java Records and Sealed Classes Explained
Master Java records for immutable data and sealed classes for controlled type hierarchies with pattern matching and practical domain modeling.
- Java Java Records vs Lombok
Records and Lombok both reduce boilerplate, but they solve overlapping problems in very different ways. Here is when to pick which, with concrete tradeoffs.
- Java Java CompletableFuture Patterns for Async Code
Master CompletableFuture composition patterns including chaining, combining, error handling, timeouts, and async pipeline design in Java.
- Java Pattern Matching with Switch Expressions in Java
Master Java pattern matching in switch expressions. Covers type patterns, guarded patterns, sealed class exhaustiveness, and record deconstruction patterns.