Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 009 / 100

Sealed classes and pattern matching in Java & Spring Boot

Model closed sets of types and let the compiler check you handled every case.

Expona Admin8 min read

Why sealed classes matter for type safety

When you're building a payment processor, an order workflow, or any system with a fixed set of allowed types, Java's sealed classes and pattern matching let you model that constraint directly in the type system. Sealed classes and pattern matching in Java & Spring Boot work together to give you exhaustive compile-time checking—catch missing cases before your code ships, not after a production incident.

The combination eliminates a common frustration: writing a switch statement over subtypes, shipping it to production, then someone adds a new subclass and forgets to update every switch. With sealed classes, the compiler flags every switch that doesn't handle the new type. You get algebraic data types—something Java developers have envied in languages like Rust and Haskell—without leaving the Spring ecosystem.

Declare a closed hierarchy with sealed interfaces

A sealed interface or class restricts which types can implement or extend it. You list the permitted subtypes explicitly. Only those types can inherit from the sealed parent; no other class in your codebase—no matter where it lives—can sneak in a new implementation.

java
sealed interface Payment permits Card, Wallet, Cash {
  // No methods needed; the types themselves are the data
}

record Card(String last4) implements Payment {}

record Wallet(String provider) implements Payment {}

record Cash() implements Payment {}

In production payment systems, this is powerful. Your domain knows exactly which payment methods exist: card, wallet, cash. You're not worried about some other team accidentally adding a Crypto class that slips through unnoticed. The sealed contract is enforced by the compiler.

Combining sealed with records is the sweet spot. Records give you immutable value objects with zero boilerplate; sealed gives you a closed type hierarchy. Together they model business rules as code:

  • Card holds the last four digits (real transactions store encrypted data, of course).
  • Wallet holds a provider name like "PayPal" or "Apple Pay".
  • Cash carries no state—it's just a marker that payment was cash.

This is cleaner and safer than an enum-based approach where you'd have to manually encode the data shape. Each variant is a real type with its own fields.

Exhaustive switch with no guessing

Once your hierarchy is sealed, you can write switch statements that the compiler verifies are exhaustive. You don't need a default case. If you forget to handle one of the permitted types, the compiler errors.

java
String describe(Payment p) {
  return switch (p) {
    case Card c -> "Card ending in " + c.last4();
    case Wallet w -> "Wallet: " + w.provider();
    case Cash c -> "Cash payment";
  };
}

This is pattern matching in action. The switch destructures each type and binds its fields to variables. No casting, no null checks, no instanceof chains. The code is declarative: for each type, here's what to do.

Now imagine you add a new payment method—say, BankTransfer:

java
sealed interface Payment permits Card, Wallet, Cash, BankTransfer {}

record BankTransfer(String iban) implements Payment {}

Every switch statement over Payment immediately shows a compiler error. You can't accidentally forget to handle the new type. This catches refactoring mistakes and incomplete feature work at compile time, not at runtime when a customer's payment fails.

Compare this to an older Java pattern where Payment is just a regular interface or abstract class. You add a new implementation, deploy it, and some switch statement quietly falls through to a default case or throws an exception. Sealed classes make that impossible.

Model domain results instead of throwing exceptions

In real systems, not every operation succeeds. Instead of throwing exceptions for expected outcomes—like "user not found" or "insufficient balance"—model the outcomes as a sealed hierarchy:

java
sealed interface PaymentResult permits Success, NotFound, InsufficientFunds, Declined {}

record Success(String transactionId) implements PaymentResult {}

record NotFound(String reason) implements PaymentResult {}

record InsufficientFunds(BigDecimal needed, BigDecimal available) implements PaymentResult {}

record Declined(String code, String message) implements PaymentResult {}

Now your payment service returns a PaymentResult, not a boolean or a thrown exception:

java
@Service
public class PaymentService {
  
  public PaymentResult processPayment(Payment method, BigDecimal amount, Account account) {
    if (account.getBalance().compareTo(amount) < 0) {
      return new InsufficientFunds(amount, account.getBalance());
    }
    
    String transactionId = callPaymentGateway(method, amount);
    if (transactionId == null) {
      return new Declined("GATEWAY_ERROR", "Payment gateway unavailable");
    }
    
    account.debit(amount);
    return new Success(transactionId);
  }
}

The caller handles each outcome with a switch:

java
PaymentResult result = paymentService.processPayment(card, amount, account);

String message = switch (result) {
  case Success s -> "Transaction " + s.transactionId() + " completed";
  case NotFound nf -> "Error: " + nf.reason();
  case InsufficientFunds isf -> 
    "Insufficient balance. Need " + isf.needed() + ", have " + isf.available();
  case Declined d -> "Payment declined: " + d.message();
};

log.info(message);

This approach has several wins:

  • The caller must handle every outcome. The compiler enforces it.
  • No silent failures hiding in try-catch blocks.
  • Each outcome can carry different data (e.g., InsufficientFunds tells you how much you need).
  • No exception overhead for expected business events.

In e-commerce and payment processing, this pattern is invaluable. You're not pretending that every operation succeeds; you're modeling reality.

Sealed classes pair naturally with records

Records (final, immutable classes with generated getters, equals, hashCode, toString) and sealed interfaces are designed to work together. A record can implement a sealed interface, and you get all the benefits:

  • Records give you immutable data with no boilerplate.
  • Sealed gives you a closed type system.
  • Pattern matching gives you exhaustive, compiler-checked handling.

In a Spring Boot application, this often looks like:

java
// Domain model
sealed interface OrderEvent permits OrderCreated, OrderPaid, OrderShipped, OrderCancelled {}

record OrderCreated(String orderId, LocalDateTime timestamp) implements OrderEvent {}
record OrderPaid(String orderId, String transactionId, LocalDateTime timestamp) implements OrderEvent {}
record OrderShipped(String orderId, String trackingNumber, LocalDateTime timestamp) implements OrderEvent {}
record OrderCancelled(String orderId, String reason, LocalDateTime timestamp) implements OrderEvent {}

// Service
@Service
public class OrderEventHandler {
  
  public void handle(OrderEvent event) {
    switch (event) {
      case OrderCreated oc -> {
        log.info("Order {} created", oc.orderId());
        // Notify warehouse
      }
      case OrderPaid op -> {
        log.info("Order {} paid with transaction {}", op.orderId(), op.transactionId());
        // Trigger fulfillment
      }
      case OrderShipped os -> {
        log.info("Order {} shipped, tracking: {}", os.orderId(), os.trackingNumber());
        // Send customer notification
      }
      case OrderCancelled oc -> {
        log.info("Order {} cancelled: {}", oc.orderId(), oc.reason());
        // Refund and notify
      }
    }
  }
}

This code is crystal clear about what events can happen and what the system does in response. There's no ambiguity, no missing cases hiding in a default clause, and no casting or type checks.

Common mistakes

Forgetting that sealed classes must be final or sealed. If you declare a sealed interface, the implementing classes must be either final (usually automatic with records) or sealed themselves if they have their own permitted subtypes. The compiler enforces this, but it's easy to forget the first time:

java
// Wrong: record is final by default, but if you use a regular class, you need final
class Card implements Payment {}  // Compile error

// Right
record Card(String last4) implements Payment {}  // Records are final

// Or
final class Card implements Payment {
  private final String last4;
  // ...
}

Using default cases when you don't need them. With a sealed hierarchy and exhaustive pattern matching, default is redundant and hides bugs:

java
// Avoid this
String describe(Payment p) {
  return switch (p) {
    case Card c -> "Card";
    case Wallet w -> "Wallet";
    default -> "Unknown";  // Now if you add a type, this silently handles it!
  };
}

// Do this instead
String describe(Payment p) {
  return switch (p) {
    case Card c -> "Card";
    case Wallet w -> "Wallet";
    case Cash c -> "Cash";
  };
}

Mixing sealed hierarchies with optional/nullable fields. Sealed classes work best when each variant is explicit. Avoid:

java
// Less clear
record Card(String last4, String optionalCvv) implements Payment {}

// Better: if CVV is sometimes absent, model it as a separate type or use Optional carefully
record Card(String last4) implements Payment {}
record CardWithCvv(String last4, String cvv) implements Payment {}

Key takeaways

  • Sealed interfaces and classes restrict which types can implement them, giving you a closed type hierarchy the compiler understands.
  • Pattern matching with switch statements over sealed types is exhaustive—add a new type and every switch fails to compile until you handle it.
  • Use sealed hierarchies to model domain outcomes (Success, NotFound, Failed) instead of throwing exceptions for expected business events.
  • Records and sealed classes are a perfect pair: immutable value objects in a type-safe, compiler-checked hierarchy.
  • Never use a default case in an exhaustive switch—it defeats the whole point of sealed types and makes you vulnerable to missing new cases.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles