Back to Blog
java-21sealed-classespattern-matchingjavadesign-patternsspring-boot

Java Sealed Classes: Model Your Domain with Exhaustive Type Safety (2026)

Sealed classes enforce closed hierarchies that the compiler can verify exhaustively. Combined with pattern matching, they replace fragile instanceof chains with type-safe, readable code.

J

JOptimize Team

May 28, 2026· 8 min read

Before sealed classes, a method that returns a Shape could return any subclass — including ones the caller doesn't know about. The caller had to add an else branch for unknown types, and the compiler couldn't verify exhaustiveness. Sealed classes change this: the compiler knows exactly which subtypes exist and enforces that you handle all of them.


Defining a Sealed Hierarchy

// Sealed interface — only these 3 implementations are permitted public sealed interface PaymentMethod permits CreditCard, BankTransfer, Cryptocurrency {} // Each permitted type is itself final (or sealed/non-sealed) public record CreditCard( String cardNumber, String cardholderName, YearMonth expiry, String cvv ) implements PaymentMethod {} public record BankTransfer( String iban, String bankName, String accountHolder ) implements PaymentMethod {} public record Cryptocurrency( String walletAddress, String network, // "ETH", "BTC", "SOL" BigDecimal fee ) implements PaymentMethod {}

Now PaymentMethod is a closed set — the compiler knows exactly 3 implementations exist.


Pattern Matching Switch: Exhaustive by Design

public BigDecimal calculateFee(PaymentMethod method, BigDecimal amount) { return switch (method) { case CreditCard cc -> amount.multiply(new BigDecimal("0.025")); // 2.5% case BankTransfer bt -> new BigDecimal("0.50"); // Flat fee case Cryptocurrency c -> c.fee(); // Custom fee per crypto // No default needed — compiler verifies ALL cases are covered // Add a 4th PaymentMethod → compile error here immediately }; }

If you add PayPal to the sealed hierarchy, every switch over PaymentMethod without default produces a compile error. The compiler forces you to handle the new type.


Replacing instanceof Chains

// BEFORE Java 17 — verbose and fragile public String describePayment(PaymentMethod method) { if (method instanceof CreditCard) { CreditCard cc = (CreditCard) method; return "Card ending in " + cc.cardNumber().substring(cc.cardNumber().length() - 4); } else if (method instanceof BankTransfer) { BankTransfer bt = (BankTransfer) method; return "Bank transfer from " + bt.bankName(); } else if (method instanceof Cryptocurrency) { Cryptocurrency crypto = (Cryptocurrency) method; return "Crypto on " + crypto.network(); } else { throw new IllegalArgumentException("Unknown payment type"); } } // AFTER Java 21 — concise, type-safe, exhaustive public String describePayment(PaymentMethod method) { return switch (method) { case CreditCard cc -> "Card ending in " + last4(cc.cardNumber()); case BankTransfer bt -> "Bank transfer from " + bt.bankName(); case Cryptocurrency c -> "Crypto on " + c.network(); }; }

Guarded Patterns

public String classifyPayment(PaymentMethod method, BigDecimal amount) { return switch (method) { case CreditCard cc when amount.compareTo(new BigDecimal("10000")) > 0 -> "High-value card payment — requires 3DS"; case CreditCard cc -> "Standard card payment"; case BankTransfer bt when bt.iban().startsWith("GB") -> "UK bank transfer — faster payments eligible"; case BankTransfer bt -> "International bank transfer"; case Cryptocurrency c -> "Crypto payment on " + c.network(); }; }

Guards (when) add runtime conditions to pattern matching — more specific cases must come before more general ones.


Modeling Domain Results: Success or Failure

// Replace Optional<T> + exceptions with explicit result types public sealed interface OrderResult permits OrderCreated, OrderRejected, OrderPending {} public record OrderCreated(Order order, String confirmationId) implements OrderResult {} public record OrderRejected(String reason, ErrorCode code) implements OrderResult {} public record OrderPending(String queuePosition) implements OrderResult {} // Service returns sealed type — no exceptions for business logic public OrderResult placeOrder(PlaceOrderRequest req) { if (!inventoryService.hasStock(req.items())) { return new OrderRejected("Insufficient stock", ErrorCode.OUT_OF_STOCK); } if (req.total().compareTo(customerCredit(req.customerId())) > 0) { return new OrderRejected("Credit limit exceeded", ErrorCode.CREDIT_LIMIT); } if (isHighDemandPeriod()) { return new OrderPending(queueService.enqueue(req)); } Order order = orderRepository.save(Order.from(req)); return new OrderCreated(order, generateConfirmationId()); } // Controller handles all cases exhaustively @PostMapping("/orders") public ResponseEntity<?> createOrder(@RequestBody PlaceOrderRequest req) { return switch (orderService.placeOrder(req)) { case OrderCreated oc -> ResponseEntity.status(201).body(oc); case OrderRejected or -> ResponseEntity.status(422).body(or); case OrderPending op -> ResponseEntity.status(202).body(op); }; }

No try/catch for business logic. No null returns. The compiler guarantees all cases are handled.


Sealed Classes with Spring Boot Jackson

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = CreditCard.class, name = "CREDIT_CARD"), @JsonSubTypes.Type(value = BankTransfer.class, name = "BANK_TRANSFER"), @JsonSubTypes.Type(value = Cryptocurrency.class, name = "CRYPTO") }) public sealed interface PaymentMethod permits CreditCard, BankTransfer, Cryptocurrency {}

JSON payload:

{ "type": "CREDIT_CARD", "cardNumber": "4111111111111111", "expiry": "2028-12" }

Jackson deserializes to the correct concrete type automatically.


Common Mistakes to Avoid

  • Adding default to switch over sealed types — defeats the exhaustiveness check; the compiler will no longer warn when you add a new permitted type
  • Using sealed classes for open extension points — if third parties need to implement your interface, sealed is wrong; use a regular interface
  • Non-final permitted classes — permitted subclasses should be final (records are final by default) or themselves sealed; non-sealed permits further subclasses but loses exhaustiveness
  • Nesting business logic in pattern match — for complex cases, call a method rather than embedding 10 lines in a case branch

Summary

Sealed classes define closed type hierarchies that the compiler can verify exhaustively. Combined with Java 21 pattern matching switch expressions, they eliminate instanceof chains, make illegal states unrepresentable, and force callers to handle every case. The domain result pattern — sealed interface Result permits Success, Failure, Pending — replaces exception-based control flow with explicit, type-safe outcomes.


Detect Type Safety Issues in Your Codebase

JOptimize flags instanceof chains that could be replaced with sealed classes, unhandled exception paths in service methods, and fragile type casting patterns.

Write safer Java with sealed types — free code modernization scan.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.