Java 14-21 introduced a trio of features that transform how you model data: records, sealed classes, and pattern matching. Together they close a decades-long gap with languages like Scala and Kotlin. This article shows what you actually do with them.

Records — immutable data without boilerplate

Before records, a simple data class was 40 lines of equals/hashCode/toString/getters. Now:

public record Order(UUID id, UUID customerId, long amountCents, Instant placedAt) {}

That’s it. You get:

  • A constructor
  • Accessor methods (order.id(), order.customerId())
  • equals and hashCode based on all components
  • A sensible toString
  • Components are final — the record is immutable

Compact constructor for validation

public record Order(UUID id, UUID customerId, long amountCents) {
    public Order {
        Objects.requireNonNull(id);
        Objects.requireNonNull(customerId);
        if (amountCents <= 0) throw new IllegalArgumentException("amount must be positive");
    }
}

Note: no parameters in the declaration — compact constructor runs before the implicit field assignment.

When to use records

Records are for data, not behavior:

  • DTOs for REST APIs
  • Event payloads
  • Configuration objects
  • Value objects (Money, Coordinates, Duration)
  • Immutable results from services

Not records:

  • JPA entities (need mutable state and no-arg constructor — use classes)
  • Objects with identity (records compare by value)
  • Anything you expect to extend (records are implicitly final)

Sealed classes — closed hierarchies

Before sealed, if you wrote interface Shape, anyone could implement it. Sealed types say: only these specific types are allowed.

public sealed interface PaymentResult
    permits PaymentResult.Succeeded, PaymentResult.Declined, PaymentResult.Pending {

    record Succeeded(String transactionId) implements PaymentResult {}
    record Declined(String reason) implements PaymentResult {}
    record Pending(String referenceId) implements PaymentResult {}
}

Why this matters: the compiler now knows the full list. You get exhaustive pattern matching — the compiler errors if you forget a case.

Pattern matching — putting it together

Old style with instanceof:

if (result instanceof PaymentResult.Succeeded s) {
    return "paid: " + s.transactionId();
} else if (result instanceof PaymentResult.Declined d) {
    return "declined: " + d.reason();
} else if (result instanceof PaymentResult.Pending p) {
    return "waiting: " + p.referenceId();
} else {
    throw new IllegalStateException();
}

Pattern matching for switch (Java 21+):

String message = switch (result) {
    case PaymentResult.Succeeded(var txId) -> "paid: " + txId;
    case PaymentResult.Declined(var reason) -> "declined: " + reason;
    case PaymentResult.Pending(var ref) -> "waiting: " + ref;
};

Cleaner in several ways:

  • Record deconstructioncase PaymentResult.Succeeded(var txId) both checks type and binds fields in one step
  • Exhaustive — because PaymentResult is sealed, the compiler knows all cases are handled. No default clause needed. If you add a new sealed subtype, every switch using this type fails to compile until you add the case.
  • Expression form — the whole switch returns a value

With guards

String label = switch (order) {
    case Order o when o.amountCents() > 100_000 -> "VIP";
    case Order o when o.amountCents() > 10_000 -> "regular";
    case Order o -> "small";
};

Nested patterns

case Shipment(var id, Address(var city, _, _), _) when city.equals("Berlin") -> ...

Deconstruct multiple levels in one pattern.

A practical example — domain errors

Sealed + records + pattern matching give you a great way to model operation results without exceptions:

public sealed interface TransferResult {
    record Success(UUID transferId) implements TransferResult {}
    record InsufficientFunds(long available, long required) implements TransferResult {}
    record AccountFrozen(String accountId, String reason) implements TransferResult {}
    record DailyLimitExceeded(long limit, long attempted) implements TransferResult {}
}

public TransferResult transfer(UUID from, UUID to, long amount) {
    if (frozen(from)) return new TransferResult.AccountFrozen(from.toString(), "compliance");
    if (balanceOf(from) < amount) return new TransferResult.InsufficientFunds(balanceOf(from), amount);
    if (dailyTotal(from) + amount > dailyLimit(from))
        return new TransferResult.DailyLimitExceeded(dailyLimit(from), amount);

    return new TransferResult.Success(performTransfer(from, to, amount));
}

// Caller:
String response = switch (result) {
    case TransferResult.Success(var id) -> "OK: " + id;
    case TransferResult.InsufficientFunds(var avail, var req) ->
        "Not enough: have " + avail + ", need " + req;
    case TransferResult.AccountFrozen(var id, var reason) ->
        "Account " + id + " frozen: " + reason;
    case TransferResult.DailyLimitExceeded(var limit, var attempted) ->
        "Over daily limit of " + limit + ": attempted " + attempted;
};

Every error case is an explicit type. Adding a new case fails every switch that hasn’t been updated. This catches whole classes of bugs that throw new PaymentException("...") lets slip.

What to keep in mind

Records don’t replace classes everywhere. For objects with behavior, mutable state, or identity-based equality, classes are still right.

Sealed types work best with small finite hierarchies. Types with dozens of subtypes aren’t a good fit — the exhaustiveness benefit is lost in noise.

Pattern matching is additive. You don’t need to rewrite existing instanceof chains. Use the new form for new code; legacy code keeps working.

Compile-time safety, not runtime magic. None of this changes performance. The JVM sees essentially the same bytecode as before. The gains are in expressiveness and bug prevention at build time.

Closing thought

Records, sealed types, and pattern matching are the biggest quality-of-life improvement Java has had since generics. They reward teams willing to lean into immutable, value-based modeling. If you’re still writing 200-line “DTO” classes by hand, the time to try these is now.