Modern Java Data Modeling
Records, sealed classes, and pattern matching combine to let modern Java express immutable, closed data models almost as concisely as dedicated functional languages.
Traditional Java data modeling meant a mutable class with private fields, getters/setters, and hand-written equals/hashCode/toString — verbose and error-prone. Modern Java (16+) adds records for concise immutable data carriers, sealed classes (17+) for closed, exhaustively-checkable hierarchies, and pattern matching (16+/21+) to destructure and branch on that data. Together they form a coherent, much more declarative style for modeling domain data.
The Traditional Approach and Its Cost
Before records, a simple immutable data holder required a constructor, private final fields, accessor methods, and manually implemented equals, hashCode, and toString — dozens of lines of boilerplate for what is conceptually just "a pair of values".
// Traditional immutable class - verbose
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
@Override public boolean equals(Object o) {
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}Records: Concise Immutable Data Carriers
A record (Java 16+) compresses that entire pattern into one line: the compiler generates the canonical constructor, private final fields, public accessor methods (named after the fields, not getX()), and value-based equals, hashCode, and toString.
A record's fields are implicitly final and the record itself is implicitly final - you cannot extend a record, and its state can never change after construction.
public record Point(int x, int y) { }
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
System.out.println(p1.x()); // 3 - accessor, not getX()
System.out.println(p1.equals(p2)); // true - value equality, generated
System.out.println(p1); // Point[x=3, y=4] - generated toStringRecords Can Still Validate and Add Behavior
Records support compact constructors for validation/normalization, and ordinary instance methods for derived behavior — they are not restricted to pure data.
public record Range(int low, int high) {
public Range { // compact constructor - no parameter list repeated
if (low > high) throw new IllegalArgumentException("low > high");
}
public int length() { return high - low; }
}
Range r = new Range(2, 9);
System.out.println(r.length()); // 7
// new Range(9, 2); // throws IllegalArgumentExceptionSealed Classes: Closed Hierarchies
Where records model "and" (a value composed of several fields), sealed interfaces/classes model "or" (a value that is exactly one of a fixed, known set of alternatives) — together they express the same shape as an algebraic data type: a sum of products.
A PaymentMethod is exactly one of these three shapes, no more, no fewer - the compiler can verify code that handles all three.
sealed interface PaymentMethod permits CreditCard, BankTransfer, Cash { }
record CreditCard(String number, String expiry) implements PaymentMethod { }
record BankTransfer(String iban) implements PaymentMethod { }
record Cash() implements PaymentMethod { }Pattern Matching: Destructuring Data
instanceof pattern matching (16+) and record deconstruction patterns in switch (21+) let you test a value's shape and simultaneously bind its component parts to new variables, replacing manual casting and accessor calls.
Object obj = new Point(3, 4);
if (obj instanceof Point p) {
System.out.println(p.x() + p.y()); // 7 - p is already a Point here
}
// Record deconstruction pattern (Java 21+)
if (obj instanceof Point(int x, int y)) {
System.out.println(x + y); // 7 - x, y bound directly, no accessor calls needed
}Combining Sealed Types, Records, and switch
The full modern-modeling idiom: a sealed interface for the closed set of shapes, records as the leaf implementations, and a switch expression using record deconstruction patterns to handle every case exhaustively — no default, no casts, no manual accessor calls.
Adding a fourth PaymentMethod variant later would make this switch fail to compile until a matching case is added.
sealed interface PaymentMethod permits CreditCard, BankTransfer, Cash { }
record CreditCard(String number, String expiry) implements PaymentMethod { }
record BankTransfer(String iban) implements PaymentMethod { }
record Cash() implements PaymentMethod { }
static String describe(PaymentMethod method) {
return switch (method) {
case CreditCard(String number, String expiry) ->
"Card ending " + number.substring(number.length() - 4);
case BankTransfer(String iban) -> "Transfer to " + iban;
case Cash c -> "Cash payment";
};
}Guarded Patterns (when clauses)
Switch patterns can carry a when guard for extra conditions beyond the shape match, without breaking exhaustiveness checking for the cases that remain unguarded — though a case with a when clause is not itself considered to exhaustively cover its type, so an unguarded fallback for that type (or a default) is usually still needed.
sealed interface Shape permits Circle, Square { }
record Circle(double radius) implements Shape { }
record Square(double side) implements Shape { }
static String classify(Shape s) {
return switch (s) {
case Circle c when c.radius() > 100 -> "huge circle";
case Circle c -> "circle";
case Square sq -> "square";
};
}Immutable Data as the Default
This entire style leans on immutability: records can't be mutated after construction, and sealed hierarchies are typically built from records rather than mutable classes. Immutable data is inherently thread-safe to share (no synchronization needed to read it), easier to reason about (no action-at-a-distance mutation), and safe to use as map keys or in sets once equals/hashCode are correct — which records provide automatically.
Choosing Classes vs Records
Use a record when the type is fundamentally a transparent carrier of its component values, where two instances with equal components should be considered equal, and where the type doesn't need mutable state or a supertype to extend. Use an ordinary class when you need mutability, need to extend another class, need to hide/encapsulate representation different from the public API, or when the "equals-by-fields" semantics a record gives you for free would be wrong (e.g. an entity type where identity matters more than field values).
Use a record when: Use a class when:
- pure data carrier - needs mutable state
- value-based equality wanted - identity-based equality wanted
- no need to extend a class - needs to extend another class
- fields fully define the value - representation differs from API
- immutability is desired - lifecycle/behavior-heavy objectPractical Usage
This trio shows up heavily in API response modeling, parser/interpreter ASTs, state machines, event types for event-driven systems, and Result/Either-style error handling — anywhere a domain naturally has a small closed set of distinctly-shaped variants.
Edge Cases
Records still permit null component values unless you validate against it explicitly in a compact constructor — a record is concise, but it does not automatically enforce non-null invariants. Also, record equality is based on the accessor methods' return values by default; if you override an accessor, generated equals/hashCode still call your override, so overriding an accessor to compute something other than the stored field can silently change equality semantics.
record Person(String name) {
public Person {
Objects.requireNonNull(name, "name must not be null");
}
}
// new Person(null); // now throws NullPointerException, not silently allowedComplete Example
A minimal but exhaustively-checked JSON model: sealed interface for the closed set of node types, records for each variant, deconstruction patterns to render them.
sealed interface Json permits JsonNull, JsonNumber, JsonString, JsonArray {}
record JsonNull() implements Json {}
record JsonNumber(double value) implements Json {}
record JsonString(String value) implements Json {}
record JsonArray(java.util.List<Json> items) implements Json {}
public class JsonDemo {
static String render(Json json) {
return switch (json) {
case JsonNull n -> "null";
case JsonNumber(double v) -> String.valueOf(v);
case JsonString(String s) -> "\"" + s + "\"";
case JsonArray(var items) -> {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < items.size(); i++) {
if (i > 0) sb.append(",");
sb.append(render(items.get(i)));
}
yield sb.append("]").toString();
}
};
}
public static void main(String[] args) {
Json doc = new JsonArray(java.util.List.of(
new JsonNumber(1), new JsonString("hi"), new JsonNull()));
System.out.println(render(doc)); // [1.0,"hi",null]
}
}Mental Model
Records answer "what data does this hold" (product types - a Point IS an x AND a y). Sealed types answer "what kind is this" (sum types - a Shape IS a Circle OR a Square OR a Triangle). Pattern matching answers "given a value, which kind is it and what's inside it" in a single expression. Together they let you model data the way you'd draw it on a whiteboard: boxes of fields, arrows of alternatives.
Quick Reference
record Point(int x, int y) { } // product type, auto equals/hashCode/toString
sealed interface Shape permits A, B { } // sum type, closed set
final record A(...) implements Shape { }
final record B(...) implements Shape { }
switch (shape) {
case A(var f1) -> ...
case B(var f2) -> ...
} // exhaustive, no default neededKey Takeaways
Records replace boilerplate immutable data classes with a one-line declaration that auto-generates the constructor, accessors, equals, hashCode, and toString.
Sealed types express a closed set of alternatives, letting the compiler verify exhaustiveness in switch expressions.
Records model 'and' (product types); sealed hierarchies model 'or' (sum types); together they mirror algebraic data types from functional languages.
Pattern matching with record deconstruction destructures values directly in a switch or instanceof, without manual casts or accessor calls.
Prefer records for pure, immutable data; prefer classes when you need mutability, inheritance, identity semantics, or encapsulated representation.
class Money { double amount; // mutable public field, no validation, manual equals needed } record Money(BigDecimal amount, String currency) { public Money { Objects.requireNonNull(amount); Objects.requireNonNull(currency); } }
A record gives immutability, generated equals/hashCode, and a place (compact constructor) to enforce invariants, in far less code than a hand-rolled mutable class.
interface Shape { } // open - anyone can implement it class Circle implements Shape { } // switch over Shape now always needs a default, even when you've handled every known case sealed interface Shape permits Circle, Square { } final class Circle implements Shape { } final class Square implements Shape { } // switch over Shape can be exhaustive without a default
Leaving a hierarchy open when it's conceptually closed loses compiler-checked exhaustiveness and lets forgotten cases silently fall through to a default.
record Account(String id, double balance) { } // treating this like a mutable entity: new Account(id, oldBalance - amount) scattered everywhere with no validation record Account(String id, BigDecimal balance) { public Account { if (balance.signum() < 0) throw new IllegalArgumentException("negative balance"); } public Account withdraw(BigDecimal amount) { return new Account(id, balance.subtract(amount)); } }
Records are immutable by design; model changes as methods that return a new record instance, not by scattering ad hoc reconstruction with no validation.
Why are records described as 'product types' and sealed hierarchies as 'sum types'?
A record's value is composed of ALL its components at once (an AND relationship - a Point is an x AND a y), which mathematically is a product. A sealed hierarchy's value is exactly ONE of its permitted subtypes (an OR relationship), which is a sum.
What does a record automatically generate that a hand-written immutable class requires you to write manually?
The canonical constructor, private final fields, public accessor methods, and value-based equals(), hashCode(), and toString().
Does a record enforce non-null fields automatically?
No. Records allow null components by default unless you add validation - typically in a compact constructor using something like Objects.requireNonNull.
Why does combining sealed interfaces with records and switch pattern matching give compiler-checked exhaustiveness?
The sealed interface fixes the complete set of possible subtypes at compile time; the compiler can then verify that a switch expression's cases cover every one of those subtypes, flagging a compile error if a case is missing.
When would you choose an ordinary class over a record for a data type?
When you need mutable state, need to extend another class, need identity-based rather than value-based equality, or need to encapsulate a representation different from the public accessor API.
What is a record deconstruction pattern, and what problem does it solve compared to instanceof-and-cast?
It lets a switch or instanceof pattern both test a value's shape and bind its component fields to new variables in one step, e.g. case Point(int x, int y) ->, eliminating the separate cast and accessor-method calls.
Why might overriding a record's accessor method be risky?
The generated equals() and hashCode() call the accessor methods, not the raw stored fields directly - if you override an accessor to return something different from the stored value, you silently change what 'equal' means for that record.
Write a sealed interface Event with two record implementations, LoginEvent(String user) and LogoutEvent(String user), and a method that prints a message for each using switch pattern matching.
sealed interface Event permits LoginEvent, LogoutEvent { } record LoginEvent(String user) implements Event { } record LogoutEvent(String user) implements Event { } static void handle(Event e) { switch (e) { case LoginEvent(String user) -> System.out.println(user + " logged in"); case LogoutEvent(String user) -> System.out.println(user + " logged out"); } }
The switch is exhaustive with no default because Event is sealed to exactly these two record types, and both are handled.
What does this print?
true is printed for equals (both instances still compute the same doubled value from equal stored fields), and 6 is printed for p1.a() (the overridden accessor doubles the stored field 3). This shows equals still calls the overridden accessor, not the raw field, so overriding accessors can subtly change equality semantics if the override weren't consistent.
Write a record Temperature(double celsius) with a compact constructor that throws IllegalArgumentException if celsius is below absolute zero (-273.15).
record Temperature(double celsius) { public Temperature { if (celsius < -273.15) { throw new IllegalArgumentException("below absolute zero"); } } } new Temperature(-300) throws IllegalArgumentException; new Temperature(20) succeeds normally. The compact constructor validates before the implicit field assignment happens.
Does this switch expression compile? Why or why not?
No, it does not compile. The switch only handles Circle but Shape is sealed to permit both Circle and Square, and there is no default clause - the compiler requires every permitted subtype to be covered (or a default) for the switch expression to be exhaustive.
record Pair(int a, int b) {
public int a() { return a * 2; } // overridden accessor
}
Pair p1 = new Pair(3, 4);
Pair p2 = new Pair(3, 4);
System.out.println(p1.equals(p2));
System.out.println(p1.a());sealed interface Shape permits Circle, Square { }
record Circle(double r) implements Shape { }
record Square(double s) implements Shape { }
static String describe(Shape shape) {
return switch (shape) {
case Circle c -> "circle";
};
}