Introduction

When the domain model grows, if‑else chains and classic switch statements become fragile. Adding a new subtype often means hunting down every place that inspects the type, and the compiler cannot warn you about a missed case. Java 21 gives us a clean solution: sealed interfaces combined with switch expressions that are exhaustive by design.

Why sealed interfaces matter

A sealed interface lets you declare exactly which classes may implement it. The compiler then knows the complete hierarchy, so a switch expression over the sealed type can be verified for completeness. If you later add a new permitted subclass, every switch that isn’t updated will produce a compile‑time error — no more silent bugs in production.

Key point: Sealed types turn an open hierarchy into a closed, compile‑time‑checked one, enabling safe pattern matching.

Defining the domain

Imagine a payment‑processing service that handles several event types. We model each event as a record implementing a sealed interface.

public sealed interface PaymentEvent permits Authorization, Capture, Refund, Void {}

public record Authorization(String transactionId, BigDecimal amount) implements PaymentEvent {}
public record Capture(String transactionId, BigDecimal amount) implements PaymentEvent {}
public record Refund(String transactionId, BigDecimal amount, String reason) implements PaymentEvent {}
public record Void(String transactionId, String reason) implements PaymentEvent {}

Each record carries only the data it needs. The permits clause makes the hierarchy explicit and closed.

Processing with switch expressions

Now we can write a single method that handles every event without a single instanceof check.

public CompletableFuture handle(PaymentEvent event) {
    return switch (event) {
        case Authorization auth -> authorize(auth.transactionId(), auth.amount());
        case Capture cap       -> capture(cap.transactionId(), cap.amount());
        case Refund ref        -> refund(ref.transactionId(), ref.amount(), ref.reason());
        case Void v            -> voidTransaction(v.transactionId(), v.reason());
        // No default needed — the compiler guarantees exhaustiveness
    };
}

private CompletableFuture authorize(String id, BigDecimal amt) { /* … */ }
private CompletableFuture capture(String id, BigDecimal amt) { /* … */ }
private CompletableFuture refund(String id, BigDecimal amt, String reason) { /* … */ }
private CompletableFuture voidTransaction(String id, String reason) { /* … */ }

Notice the pattern variables (auth, cap, …) — they give you typed access to the record components instantly. The switch expression returns a CompletableFuture, so the whole pipeline stays asynchronous.

Handling new cases safely

Six months later the business adds a Chargeback event. You simply add a new record and update the permits list:

public sealed interface PaymentEvent permits Authorization, Capture, Refund, Void, Chargeback {}

public record Chargeback(String transactionId, BigDecimal amount, String code) implements PaymentEvent {}

Recompiling the project now flags the handle method with an error: the switch expression does not cover all permitted subtypes. You add the missing arm, run the tests, and deploy — zero risk of an unhandled event slipping through.

Performance note

Switch expressions compile to an efficient tableswitch or lookupswitch bytecode when the target type is a sealed interface with a modest number of implementations. There’s no reflection, no visitor boilerplate, and the JIT can inline the small handler methods. In micro‑benchmarks the pattern‑matching version is on par with a hand‑written if‑else chain, while being far easier to maintain.

Putting it together

Sealed interfaces plus switch expressions give you a domain‑driven way to model finite state spaces — payment events, order statuses, command types, you name it. The compiler becomes your safety net, and the code reads like a specification rather than a maze of conditionals. Next time you reach for a sprawling if‑else block, ask yourself whether a sealed hierarchy could make the intent explicit and the maintenance burden disappear.