What you'll learn

  • Records and compact constructors
  • Immutability and value-based equality
  • Sealed interfaces and permits
  • Record patterns and guarded cases in switch
  • Why exhaustive switches catch bugs

Modern Java (16–21) added features that make data-heavy code — exactly what integrations are full of — far more concise and safer: records for data, sealed types for closed hierarchies, and pattern matching to work with both.

Records: data classes in one line

record Money(double amount, String currency) { }

That single line gives you:

  • a constructor new Money(100, "USD");
  • accessor methods amount() and currency() (no get prefix);
  • equals and hashCode based on the values;
  • a readable toString(): Money[amount=100.0, currency=USD];
  • final fields — records are immutable.

Compact constructors: validate and normalise

record Money(double amount, String currency) {
    Money {
        if (amount < 0) throw new IllegalArgumentException("Negative amount");
        currency = currency.toUpperCase();     // normalise before the fields are set
    }

    Money plus(Money other) {                  // records can have methods
        return new Money(amount + other.amount, currency);   // return a new one — never modify
    }
}

Records are perfect for DTOs, API payloads, map keys, stream intermediate results and anything that is “just data”. Use a normal class when you need mutable state or inheritance.

Sealed types: a closed set of possibilities

sealed interface Event permits OrderPlaced, OrderShipped, OrderCancelled { }
record OrderPlaced(String id, Money value) implements Event { }
record OrderShipped(String id, String tracking) implements Event { }
record OrderCancelled(String id, String reason) implements Event { }

sealed ... permits says: these three are the only kinds of Event. Nobody can add a fourth elsewhere. That sounds like a restriction — but it’s what lets the compiler check your logic.

Pattern matching for switch (Java 21)

static String describe(Event e) {
    return switch (e) {
        case OrderPlaced(String id, Money(double amt, String cur)) when amt > 1000 ->
                id + " placed: BIG order of " + amt + " " + cur;
        case OrderPlaced(String id, Money value) -> id + " placed for " + value.amount();
        case OrderShipped(String id, String tracking) -> id + " shipped, tracking " + tracking;
        case OrderCancelled c -> c.id() + " cancelled: " + c.reason();
    };
}

Three features work together here:

  • Type patterns — case OrderCancelled c tests the type and gives you a typed variable.
  • Record patterns — OrderPlaced(String id, Money(double amt, String cur)) takes the record apart, even nested records, in one step.
  • Guards — when amt > 1000 adds a condition. Order matters: the more specific case comes first.

And because Event is sealed, the switch is exhaustive: no default is needed. If someone later adds OrderReturned to the permits list, every switch that doesn’t handle it fails to compile — the compiler finds every place you need to update. With if/else instanceof chains, that bug would only show up at runtime.

The full example

// Records.java — records, compact constructors, sealed interfaces and pattern matching (Java 21).
import java.util.List;

public class Records {

    // A record: an immutable data carrier with constructor, getters, equals, hashCode, toString
    record Money(double amount, String currency) {
        // Compact constructor: validate (and normalise) the fields
        Money {
            if (amount < 0) throw new IllegalArgumentException("Negative amount");
            currency = currency.toUpperCase();
        }

        Money plus(Money other) {
            if (!currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch");
            return new Money(amount + other.amount, currency);   // records are immutable: return a new one
        }
    }

    // Sealed: only these three types can implement Event
    sealed interface Event permits OrderPlaced, OrderShipped, OrderCancelled { }
    record OrderPlaced(String id, Money value) implements Event { }
    record OrderShipped(String id, String tracking) implements Event { }
    record OrderCancelled(String id, String reason) implements Event { }

    // Pattern matching for switch (Java 21): record patterns + exhaustiveness
    static String describe(Event e) {
        return switch (e) {
            case OrderPlaced(String id, Money(double amt, String cur)) when amt > 1000 ->
                    id + " placed: BIG order of " + amt + " " + cur;
            case OrderPlaced(String id, Money value) -> id + " placed for " + value.amount() + " " + value.currency();
            case OrderShipped(String id, String tracking) -> id + " shipped, tracking " + tracking;
            case OrderCancelled c -> c.id() + " cancelled: " + c.reason();
        };   // no default needed: the compiler knows every case is covered
    }

    public static void main(String[] args) {
        Money a = new Money(100, "usd");
        Money b = new Money(25.5, "USD");
        System.out.println(a.plus(b));                        // Money[amount=125.5, currency=USD]
        System.out.println(a.equals(new Money(100, "USD")));  // true: value-based equality

        List<Event> events = List.of(
                new OrderPlaced("PO-1", new Money(250, "usd")),
                new OrderPlaced("PO-2", new Money(4800, "eur")),
                new OrderShipped("PO-1", "1Z999"),
                new OrderCancelled("PO-3", "customer request"));
        events.forEach(e -> System.out.println(describe(e)));
    }
}

Run it with Java 21: java Records.java.

Try it yourself

Add an OrderReturned(String id, int units) event: add it to permits, run the program and read the compiler error, then handle it in describe.

Show a solution
sealed interface Event permits OrderPlaced, OrderShipped, OrderCancelled, OrderReturned { }
record OrderReturned(String id, int units) implements Event { }

// in describe(...):
case OrderReturned(String id, int units) -> id + " returned " + units + " unit(s)";

Before adding the case, the compiler reports that the switch does not cover all possible input values — exactly the safety net sealed types give you.

Key takeaways

  • Records are immutable data classes with constructor, accessors, equals, hashCode and toString built in.
  • Compact constructors validate and normalise.
  • Sealed interfaces define a closed set of types.
  • Pattern matching in switch deconstructs records, supports guards, and is checked for exhaustiveness.

Example files