Skip to content
Codeloom
Java

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.

·6 min read · By Codeloom
Intermediate 11 min read

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

FeatureRecordLombok @ValueRegular Class
BoilerplateNoneAnnotationFull
ImmutabilityEnforcedConventionManual
InheritanceCannot extendCan extendCan extend
Extra fieldsNot allowedAllowedAllowed
Compile-timeBuilt-inAnnotation processorN/A
SerializationWorks naturallyNeeds configManual

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

  1. Start with records for value objects: Money, EmailAddress, DateRange, Coordinate are ideal candidates.
  2. Use sealed interfaces for result types: Replace exception-based error handling with sealed result hierarchies.
  3. Nest records inside sealed interfaces: Keep related types together and reduce file count.
  4. Let the compiler enforce exhaustiveness: Avoid default in switch expressions over sealed types so the compiler catches missing cases.
  5. 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.