Modern Java Data Modeling
Records, sealed types, and pattern matching as a coherent system for data-oriented programming
Learning Objectives
By the end of this module you will be able to:
- Define Java records and explain when to use them versus regular classes.
- Design a sealed class hierarchy to model a domain as a closed set of variants.
- Write exhaustive switch expressions over sealed types using pattern matching.
- Compare Java's approach to sum types with Rust enums and TypeScript discriminated unions.
- Apply data-oriented programming principles using records and sealed types together.
Core Concepts
Algebraic Data Types: the underlying idea
Before diving into Java syntax, it helps to name the pattern these features implement. Algebraic data types (ADTs) come from functional programming languages like Haskell and OCaml. They combine two primitive forms:
- Product types — values that hold all of several fields simultaneously (AND composition). A
Pointwith anxand ay. - Sum types — values that are one of a fixed set of alternatives (OR composition). A
Shapethat is either aCircleor aRectangle.
The cardinality of a product type is the product of its components' cardinalities. The cardinality of a sum type is the sum of its variants' cardinalities. The names are literal.
Pattern matching on ADTs provides type-safe case analysis where the compiler verifies at compile time that every variant is handled — what is called exhaustiveness checking. This is the key property that distinguishes ADTs from ordinary inheritance hierarchies, which rely on runtime dispatch and cannot provide the same static guarantees.
Java 21 delivers both halves of this abstraction: records for product types and sealed classes/interfaces for sum types, with pattern matching as the mechanism for consuming them.
Records: product types in Java
A record is a transparent, immutable data carrier. You declare the components in the header; the compiler generates equals(), hashCode(), toString(), and accessor methods automatically:
record Point(double x, double y) {}
That single line is semantically equivalent to a full class with two final fields, a canonical constructor, and the three standard methods. Kotlin data classes (2016) demonstrated the value of this pattern on the JVM, and Java formalized it with JEP 395 in Java 16.
According to the Project Amber design notes, records are intentionally transparent: their API is their state. There is no hiding of representation. This is what makes them good for data carriers and unsuitable when encapsulation of internal state is the goal.
When to use a record vs a regular class:
| Situation | Use |
|---|---|
| Data transfer object, value object, message payload | Record |
| Encapsulating mutable state or hiding implementation | Regular class |
| Extending another class | Regular class (records cannot extend) |
| Implementing one or more interfaces | Either — records can implement interfaces |
A record is implicitly final. You cannot subclass a record. If you need a type hierarchy, sealed interfaces are the composition point — not the record itself.
Sealed types: sum types in Java
A sealed class or interface restricts which other types may extend or implement it. You name the permitted subtypes explicitly with the permits clause:
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}
The permitted subtypes must live in the same package or module, and each must declare one of three modifiers:
final— no further subclassing.sealed— further restricted subclassing (nested hierarchy).non-sealed— opens the hierarchy back up, breaking exhaustiveness guarantees for that branch.
This is specified in JEP 409, finalized in Java 17. The design was directly influenced by Scala's sealed classes and traits, which had provided the same ADT semantics for years. The Java design notes explicitly cite algebraic data types as the motivation.
Sealed types occupy the middle ground between final (no subclasses) and open inheritance (unlimited subclasses). The set of variants is known, closed, and compiler-enforced.
Pattern matching: consuming sealed types
Pattern matching for instanceof (Java 16, JEP 394) was the first step. Pattern matching for switch (finalized in Java 21, JEP 441) is the payoff. When the selector of a switch expression is a sealed type, the compiler can verify that all permitted subtypes are covered — no default clause needed:
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
};
If you add a fourth permitted subtype to Shape without updating this switch, the code will not compile. That is the exhaustiveness guarantee.
Record patterns (JEP 440, Java 21) go further, letting you destructure record components inline within the pattern itself:
double area = switch (shape) {
case Circle(var r) -> Math.PI * r * r;
case Rectangle(var w, var h) -> w * h;
case Triangle(var b, var h) -> 0.5 * b * h;
};
According to the JEP 440 design, record patterns can be nested with type patterns, enabling declarative and composable data navigation in a single expression.
Exhaustiveness and null
JEP 441 also relaxes Java's historical null-hostility in switch. Previously, a null selector always threw NullPointerException. With pattern matching switch, you can handle null explicitly as a case:
String describe = switch (shape) {
case null -> "nothing";
case Circle c -> "a circle";
case Rectangle r -> "a rectangle";
case Triangle t -> "a triangle";
};
If you omit the null case, the switch still throws on null — the historical behavior is preserved as the default. Exhaustiveness checking covers all non-null subtypes; null must be opted into.
Data-oriented programming
Records, sealed classes, and record patterns together constitute what the Java language team calls data-oriented programming. The approach, articulated by Brian Goetz, frames complex domain modeling as the combination of product types and sum types with pattern matching — complementing object-oriented programming rather than replacing it.
The practical payoff is the ability to make illegal states unrepresentable. When a sealed hierarchy with exhaustiveness-checked pattern matching constrains what values can exist, entire classes of runtime errors move to compile time.
Compare & Contrast
Java records vs Rust structs vs Python dataclasses
| Java Record | Rust Struct | Python Dataclass | |
|---|---|---|---|
| Immutability | Always — all fields are final | Default — must opt out with mut | Optional via frozen=True |
| Structural equality | equals() generated on all components | PartialEq must be derived | __eq__ generated unless disabled |
| Inheritance | Cannot extend classes, can implement interfaces | No inheritance | Normal class inheritance |
| Customization | Compact constructor for validation | Custom impl blocks | __post_init__ |
| Destructuring in function signatures | Not supported | Yes — fn f(Point { x, y }: Point) | Not supported |
The most significant gap versus Rust: Java has no destructuring in function parameters. Decomposition happens at the call site with record patterns in a switch or instanceof check.
Java sealed types vs Rust enums vs TypeScript discriminated unions
Key differences to internalize:
- Rust enums are a single nominal type; data is embedded directly in enum variants. One declaration, one type.
- TypeScript discriminated unions are structural — any object with the right discriminant field qualifies. No class hierarchy needed.
- Java sealed types are nominal class hierarchies. Each variant (
Ok,Err) is a distinct named type. The sealed interface knits them into a closed set. This means more declarations but also more type-level granularity (e.g., you can accept onlyOk<T, E>as a parameter).
What Java does not yet have:
- Destructuring in method parameters (no
void f(Circle(var r) c)syntax). - Structural equality across sealed hierarchies — only records get generated
equals(); asealed interfaceitself has no structural equality semantics unless all variants are records. - Enum-like data embedding in a single declaration; Java requires separate record or class declarations per variant.
Worked Example
Modeling a payment event stream
Suppose you are building an event sourcing system for payment processing. Payments transition through states, and each state carries different data. This is a classic sum-of-products modeling problem.
Step 1: define the variants as records
sealed interface PaymentEvent permits
PaymentInitiated,
PaymentAuthorized,
PaymentFailed,
PaymentRefunded {}
record PaymentInitiated(String paymentId, long amountCents, String currency)
implements PaymentEvent {}
record PaymentAuthorized(String paymentId, String authCode, Instant authorizedAt)
implements PaymentEvent {}
record PaymentFailed(String paymentId, String reason, int httpStatusCode)
implements PaymentEvent {}
record PaymentRefunded(String paymentId, long refundedCents, Instant refundedAt)
implements PaymentEvent {}
Each record is a product type — it holds all of its fields simultaneously. The sealed interface is a sum type — a PaymentEvent is exactly one of the four variants.
Step 2: process events exhaustively
String describe(PaymentEvent event) {
return switch (event) {
case PaymentInitiated(var id, var amount, var currency) ->
"Payment %s initiated: %d %s".formatted(id, amount, currency);
case PaymentAuthorized(var id, var code, var at) ->
"Payment %s authorized with code %s at %s".formatted(id, code, at);
case PaymentFailed(var id, var reason, var status) ->
"Payment %s failed (%d): %s".formatted(id, status, reason);
case PaymentRefunded(var id, var cents, var at) ->
"Payment %s refunded %d cents at %s".formatted(id, cents, at);
};
}
No default clause. If a fifth variant — say PaymentDisputed — is added to the permits clause, this switch fails to compile until you add its case. The compiler enforces completeness.
Step 3: nested patterns for complex dispatch
If you need to route events, you can nest conditions inline using guards:
void route(PaymentEvent event) {
switch (event) {
case PaymentFailed(var id, var reason, var status)
when status >= 500 ->
alertOps(id, reason);
case PaymentFailed(var id, var reason, var status) ->
logClientError(id, reason);
case PaymentAuthorized pa ->
notifyMerchant(pa.paymentId());
default -> {} // other events handled elsewhere
};
}According to the exhaustiveness design notes, the compiler must analyze sealed hierarchies and component types recursively when record patterns are nested. Deep nesting can make exhaustiveness analysis harder to read for humans even when the compiler handles it correctly. Prefer flat hierarchies when possible.
Active Exercise
Exercise 1: model a command bus
Model a simple command bus for a task management system. The system supports three commands: CreateTask (with a title and owner), AssignTask (with a task ID and assignee), and CompleteTask (with a task ID and completion timestamp).
- Define a sealed interface
TaskCommandwith the three variants as records. - Write a method
String summarize(TaskCommand cmd)using an exhaustive switch expression with record patterns. - Add a fourth command
DeleteTask(String taskId, String deletedBy)and observe what happens to the switch before you add the new case.
Exercise 2: recursive sealed hierarchy
Model a simplified JSON value type:
JsonNull— no fields.JsonBool(boolean value)JsonNumber(double value)JsonString(String value)JsonArray(List<JsonValue> elements)JsonObject(Map<String, JsonValue> fields)
Implement String prettyPrint(JsonValue value, int indent) recursively. Pay attention to what happens when JsonArray and JsonObject contain nested JsonValue instances — you will need to call prettyPrint recursively on their contents.
JsonArray and JsonObject reference JsonValue. This means JsonValue cannot use a permits clause that references types defined after it in the same file unless they are all in the same compilation unit. In practice, keep all variants in the same file or package.
Common Misconceptions
"Records are like JavaBeans with less boilerplate"
Records are not just shorthand for POJOs. JavaBeans are mutable by convention — they have getters and setters. Records are unconditionally immutable: all components are final, and there are no setters. The intent is different: a record is its data, transparently. Use a regular class with a builder when you need mutability or complex construction logic.
"Sealed classes enforce runtime safety"
Sealed classes are a compile-time contract. At runtime, the JVM does not prevent reflection-based subclassing workarounds. The safety guarantee is about the compiler's ability to reason about your code — specifically, exhaustiveness checking in switch expressions. Do not rely on sealed for security-critical invariants that must hold against adversarial code.
"You always need a default clause to be safe"
With sealed types and pattern matching switch, a default clause is not just unnecessary — it can be counterproductive. If you write default -> throw new AssertionError("unreachable") and later add a new variant to the sealed hierarchy, the switch will still compile, and the new variant will silently hit the default. Omitting default turns the compiler into your completeness checker.
"A sealed interface provides structural equality for its hierarchy"
Only record variants get auto-generated equals() and hashCode(). If some variants are regular classes, you must implement those methods yourself. A sealed interface itself provides no equality semantics — it only closes the hierarchy.
"Record patterns are available in Java 17"
Record patterns were finalized in Java 21 via JEP 440. Pattern matching for instanceof arrived in Java 16, and pattern matching for switch finalized in Java 21 as well. If you are on Java 17 LTS, you have sealed classes and basic instanceof patterns but not record destructuring in switch.
Key Takeaways
- Records are product types; sealed types are sum types. Together they give Java the two halves of algebraic data types, enabling you to model data as composable, closed structures rather than open inheritance hierarchies.
- Sealed types make the compiler your completeness checker. When a switch expression over a sealed type omits a variant, the code does not compile. Omitting default is correct practice — it keeps exhaustiveness checking active as the hierarchy evolves.
- Record patterns enable inline destructuring. Rather than calling accessors after matching, record patterns let you bind component values directly in the pattern: case Circle(var r) -> ... Patterns can be nested for deep decomposition.
- Java's approach is nominal, not structural. Each variant is a distinct named type. This differs from TypeScript discriminated unions (structural) and is more verbose than Rust enums (single declaration). The trade-off is more type-level granularity — you can express functions that only accept specific variants.
- Data-oriented programming complements OOP. Records and sealed types are not a replacement for classes and polymorphism. They are a complementary style suited to data-heavy, transformation-heavy code — event sourcing, ASTs, protocol messages, configuration — where the shape of data is the domain.
Further Exploration
Official JEPs and Specifications
- JEP 409: Sealed Classes — The canonical specification for the feature
- JEP 440: Record Patterns — How destructuring works in patterns
- JEP 441: Pattern Matching for switch — Full specification including guards, null handling, and exhaustiveness rules
Project Amber Design Notes
- Design Notes: Data Classes and Sealed Types for Java — The original design rationale
- Design Notes: Data-Oriented Programming for Java Beyond Records — Brian Goetz on how records and sealed types constitute a data-oriented programming model
- Design Notes: Patterns Exhaustiveness — Deep dive on exhaustiveness analysis, including nested record patterns
Extended Resources
- Algebraic Data Types and Pattern Matching with Java — A worked walkthrough connecting the ADT tradition to modern Java syntax
- Inside the Language: Sealed Types — Java Magazine on sealed types in context