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.
What you'll learn
- ✓How records eliminate boilerplate for data classes
- ✓How sealed classes restrict type hierarchies
- ✓How to combine records and sealed classes for domain modeling
- ✓Pattern matching with sealed hierarchies
Prerequisites
None — this post is self-contained.
Java records and sealed classes, introduced as stable features in Java 16 and 17 respectively, address two long-standing pain points: the verbosity of simple data-carrying classes and the inability to restrict which classes can extend a type. Together, they enable expressive, type-safe domain models with minimal code.
Records: Immutable Data Without Boilerplate
A record is a concise way to declare a class whose primary purpose is carrying data. The compiler automatically generates the constructor, accessor methods, equals, hashCode, and toString.
// Before records: 30+ lines of boilerplate
public class Point {
private final double x;
private final double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double x() { return x; }
public double y() { return y; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
// With records: one line
public record Point(double x, double y) {}
Records are implicitly final, their fields are implicitly private and final, and they extend java.lang.Record. You cannot add instance fields beyond the record components.
Compact Constructors
Records support compact constructors for validation without repeating parameter assignments:
public record Email(String address) {
public Email {
if (address == null || !address.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + address);
}
address = address.toLowerCase().trim();
}
}
// Usage
var email = new Email("USER@Example.COM");
System.out.println(email.address()); // user@example.com
The compact constructor omits the parameter list and the explicit field assignments. The compiler inserts this.address = address at the end automatically.
Custom Methods and Static Factories
Records can have additional methods, static methods, and implement interfaces:
public record Money(double amount, String currency) implements Comparable<Money> {
public static Money usd(double amount) {
return new Money(amount, "USD");
}
public static Money eur(double amount) {
return new Money(amount, "EUR");
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount + other.amount, this.currency);
}
@Override
public int compareTo(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return Double.compare(this.amount, other.amount);
}
}
Records as DTOs and Value Objects
Records are natural fits for data transfer objects, API response models, and configuration holders:
// API response
public record ApiResponse<T>(T data, int statusCode, String message) {}
// Configuration
public record DatabaseConfig(String host, int port, String database, String username) {
public String connectionUrl() {
return "jdbc:postgresql://%s:%d/%s".formatted(host, port, database);
}
}
// Event
public record OrderPlaced(String orderId, String customerId, double total, java.time.Instant timestamp) {}
Sealed Classes: Controlled Hierarchies
Sealed classes restrict which classes can extend them using the permits clause. This gives you a closed set of subtypes that the compiler can reason about.
public sealed interface Shape permits Circle, Rectangle, Triangle {
double area();
}
public record Circle(double radius) implements Shape {
public double area() {
return Math.PI * radius * radius;
}
}
public record Rectangle(double width, double height) implements Shape {
public double area() {
return width * height;
}
}
public record Triangle(double base, double height) implements Shape {
public double area() {
return 0.5 * base * height;
}
}
Permitted subclasses must be one of: final, sealed (continuing the restriction), or non-sealed (opening up for further extension).
Exhaustive Pattern Matching
The real power of sealed classes comes from pattern matching in switch expressions. Because the compiler knows every possible subtype, it can verify that your switch is exhaustive:
public static String describe(Shape shape) {
return switch (shape) {
case Circle c -> "Circle with radius %.2f".formatted(c.radius());
case Rectangle r -> "Rectangle %s x %s".formatted(r.width(), r.height());
case Triangle t -> "Triangle with base %.2f".formatted(t.base());
// No default needed: compiler knows all cases are covered
};
}
If you add a new permitted subclass, every switch expression that matches on Shape will produce a compile error until you handle the new case. This is a significant safety improvement over instanceof chains with a catch-all else.
Guarded Patterns
Pattern matching supports guards for additional conditions:
public static double applyDiscount(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 10 -> c.area() * 0.9;
case Circle c -> c.area();
case Rectangle r when r.width() == r.height() -> r.area() * 0.95;
case Rectangle r -> r.area();
case Triangle t -> t.area();
};
}
Combining Records and Sealed Classes
The combination of records and sealed classes is particularly powerful for modeling domain events, AST nodes, and state machines.
Domain Events
public sealed interface DomainEvent permits OrderCreated, OrderShipped, OrderCancelled {
String orderId();
java.time.Instant occurredAt();
}
public record OrderCreated(String orderId, String customerId, double total,
java.time.Instant occurredAt) implements DomainEvent {}
public record OrderShipped(String orderId, String trackingNumber,
java.time.Instant occurredAt) implements DomainEvent {}
public record OrderCancelled(String orderId, String reason,
java.time.Instant occurredAt) implements DomainEvent {}
// Event handler with exhaustive matching
public static void handleEvent(DomainEvent event) {
switch (event) {
case OrderCreated e -> System.out.printf(
"Order %s created for customer %s: $%.2f%n",
e.orderId(), e.customerId(), e.total());
case OrderShipped e -> System.out.printf(
"Order %s shipped: tracking %s%n",
e.orderId(), e.trackingNumber());
case OrderCancelled e -> System.out.printf(
"Order %s cancelled: %s%n",
e.orderId(), e.reason());
}
}
Expression Trees
public sealed interface Expr permits Literal, Add, Multiply, Negate {
double evaluate();
}
public record Literal(double value) implements Expr {
public double evaluate() { return value; }
}
public record Add(Expr left, Expr right) implements Expr {
public double evaluate() { return left.evaluate() + right.evaluate(); }
}
public record Multiply(Expr left, Expr right) implements Expr {
public double evaluate() { return left.evaluate() * right.evaluate(); }
}
public record Negate(Expr operand) implements Expr {
public double evaluate() { return -operand.evaluate(); }
}
// Pretty printer using pattern matching
public static String prettyPrint(Expr expr) {
return switch (expr) {
case Literal l -> String.valueOf(l.value());
case Add a -> "(%s + %s)".formatted(prettyPrint(a.left()), prettyPrint(a.right()));
case Multiply m -> "(%s * %s)".formatted(prettyPrint(m.left()), prettyPrint(m.right()));
case Negate n -> "(-%s)".formatted(prettyPrint(n.operand()));
};
}
Result Type
public sealed interface Result<T> permits Result.Success, Result.Failure {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}
static <T> Result<T> of(T value) { return new Success<>(value); }
static <T> Result<T> error(String msg) { return new Failure<>(msg); }
}
// Usage
public static Result<Integer> divide(int a, int b) {
if (b == 0) return Result.error("Division by zero");
return Result.of(a / b);
}
public static void main(String[] args) {
var result = divide(10, 3);
String output = switch (result) {
case Result.Success<Integer> s -> "Result: " + s.value();
case Result.Failure<Integer> f -> "Error: " + f.error();
};
System.out.println(output);
}
Records Limitations
Records are not a replacement for all classes. Be aware of these constraints:
- Records cannot extend other classes (they implicitly extend
Record) - Record fields are always
final; you cannot have mutable records - You cannot add instance fields beyond the declared components
- Records are not suitable for JPA entities because they lack a no-arg constructor and mutable setters
// This will NOT work as a JPA entity
// @Entity
// public record User(Long id, String name) {} // No no-arg constructor
// Use a regular class for JPA entities
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
// getters, setters, constructors...
}
Wrapping Up
Records eliminate the boilerplate of data-carrying classes while enforcing immutability by default. Sealed classes give you closed type hierarchies with compiler-verified exhaustive pattern matching. Together, they let you model domains with type safety and minimal code. Use records for DTOs, value objects, and event payloads. Use sealed classes when you need a fixed set of variants that the compiler can check. The combination produces code that is both concise and robust.
Related articles
- 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.
- 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.
- 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.