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 final by default. Once created, a record’s state cannot change.
  • Concise: No more boilerplate. The compiler generates getters, equals(), hashCode(), and toString().
  • 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 switch expressions (covered in the next chapter), the compiler knows exactly all possible types, so you don’t need a default case.

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:

  1. final: Cannot be extended further (the hierarchy ends here).
  2. sealed: Can be extended further, but only by specific classes it permits.
  3. 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.

Legacy Class (Java 8)
public class User {
  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()...
}
➡️
Modern Record (Java 17+)
record User(String name) {}

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() or Collections.unmodifiableList().