Records and Sealed Classes
Modern Java is moving away from the “everything is a complex object” philosophy toward Data-Oriented Programming. Two of the most important tools for this are Records and Sealed Classes.
0. The Problem: Data as Objects
Imagine you are building a system to process thousands of financial transactions per second. You need a simple way to pass data between components—a Transaction with an id, amount, and currency.
In older Java versions, treating this simple “data carrier” as a full-fledged object required writing a class with private fields, getters, constructors, equals(), hashCode(), and toString(). This led to the “Boilerplate Problem,” where 90% of the code was just ceremony.
Analogy: Imagine wanting to send a simple postcard, but the post office forces you to buy a heavy-duty combination safe, hire an armored truck, and write a 10-page manifest just to deliver a “Hello” message. That’s what creating a POJO (Plain Old Java Object) used to feel like. Records are the postcards of Java.
1. Records (Data Carriers)
Before Java 14, creating a simple data carrier (POJO) required dozens of lines of boilerplate (Getters, equals, hashCode, toString).
- A Record is an immutable data carrier that generates all of this for you in one line.
public record User(String name, String email, int age) {}
Key Features of Records:
- Immutable: All fields are
finalby default. Once created, a record’s state cannot change. - Concise: No more boilerplate. The compiler generates getters,
equals(),hashCode(), andtoString(). - Canonical Constructor: Automatically handles assignment of all fields.
- Validation: You can add validation logic in a “Compact Constructor” without redefining the parameters.
public record User(String name, String email) {
// Compact Constructor: Notice there are no parameters listed here!
public User {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be blank");
}
// Implicitly, this.name = name and this.email = email happens here
}
}
War Story: The Buggy Cache Key
At Company X, developers used a standard class UserContext as a key in a HashMap cache. A junior engineer forgot to override equals() and hashCode(). Because the default implementation uses memory addresses, identical contexts were treated as different keys, leading to a massive cache-miss storm that brought down the microservice.
Switching to public record UserContext(String userId, String role) {} instantly solved this because Records guarantee correct, value-based equals() and hashCode() implementations out of the box.
2. Sealed Classes (Restricted Hierarchies)
Traditionally, a class could be extended by anything unless it was final. Sealed Classes give you a middle ground: you define exactly which classes are allowed to extend your class.
public sealed class Shape permits Circle, Square, Triangle {}
public final class Circle extends Shape { ... }
public final class Square extends Shape { ... }
public final class Triangle extends Shape { ... }
Why use Sealed Classes?
- Security: Prevents unauthorized subclasses. In a large codebase, you don’t want external libraries or other teams creating unexpected implementations of your core domain models.
- Exhaustiveness: When using
switchexpressions (covered in the next chapter), the compiler knows exactly all possible types, so you don’t need adefaultcase.
The “Permits” Clause and Subclass Constraints
When you declare a sealed class, every permitted subclass must explicitly declare how it continues the hierarchy. It has three choices:
final: Cannot be extended further (the hierarchy ends here).sealed: Can be extended further, but only by specific classes it permits.non-sealed: Breaks the seal, allowing any class to extend it from this point onward.
public sealed class PaymentMethod permits CreditCard, PayPal, Crypto {}
// 1. Final: The end of the line.
public final class CreditCard extends PaymentMethod {}
// 2. Sealed: Continues the restricted hierarchy.
public sealed class Crypto extends PaymentMethod permits Bitcoin, Ethereum {}
public final class Bitcoin extends Crypto {}
public final class Ethereum extends Crypto {}
// 3. Non-sealed: Opens the hierarchy back up to the wild.
public non-sealed class PayPal extends PaymentMethod {}
3. Interactive: Boilerplate Shredder
See how much code you save using Records.
private final String name;
public User(String n) {this.name=n;}
public String getName() {return name;}
@Override public boolean equals(Object o)...
@Override public int hashCode()...
@Override public String toString()...
}
4. Best Practices
- DTOs: Use Records for Data Transfer Objects (fetching data from DB or API).
- Domain Logic: Use Sealed Classes for State Machines or Command patterns.
- Immutability: Remember that while a Record is final, its fields (like a List) may still be mutable elements themselves. Always use
List.of()orCollections.unmodifiableList().