Closed hierarchies for safer Java domain design
Java sealed interfaces and classes, introduced in Java 17, give application code a precise way to describe a closed set of permitted subtypes. Instead of accepting any implementation, a type states exactly which classes may extend or implement it.
This restriction supports exhaustive pattern matching. When every possible result is known, a switch over a sealed hierarchy can be checked by the compiler, so adding a branch without handling the new case becomes a build failure rather than a runtime surprise.
The feature is most valuable in business systems with deliberate boundaries: gateway responses, order states, authentication decisions, and categorised API failures. It complements records, enums, abstract classes, and the Strategy pattern rather than replacing them.
For teams in Sydney and Melbourne, checkout services commonly quote Australian dollars with GST included, while Australian shoppers routinely use contactless cards and mobile wallets. Businesses covered by the Privacy Act 1988 must handle personal information under the Australian Privacy Principles and assess eligible data breaches under the Notifiable Data Breaches scheme. These conditions reward explicit, auditable outcomes.
Model business outcomes as a closed hierarchy
Payment APIs often produce several valid outcomes that should not be represented as loosely related exceptions or arbitrary strings. A sealed interface can describe each outcome as a record containing only the data required for that result.
sealed interface PaymentResult
permits Captured, Declined, PendingReview {
record Captured(String receiptId, BigDecimal amount)
implements PaymentResult {}
record Declined(String reason)
implements PaymentResult {}
record PendingReview(String reference)
implements PaymentResult {}
}
On Java 21 and later, pattern matching can process the entire permitted set without a default branch:
String outcome = switch (result) {
case Captured ok -> "Paid: " + ok.receiptId();
case Declined no -> "Declined: " + no.reason();
case PendingReview review ->
"Awaiting review: " + review.reference();
};
The compiler verifies that every possible result is handled. If PendingReview is added later, the application fails to compile until the switch is updated. This turns changing business rules into visible code changes and makes unit tests easier to structure around meaningful outcomes.
Control subclassing without losing flexibility
Sealed classes serve a different purpose. They retain shared state and behaviour while limiting inheritance, which suits a base such as Report with only SalesReport and ComplianceReport below it. Constructors can enforce invariants that every valid report must satisfy.
The permits clause names direct subtypes, while each subtype must itself be final, sealed, or non-sealed. A non-sealed branch creates a deliberate escape hatch when an approved extension point is needed, so the design can remain closed without becoming permanently rigid.
| Design choice | Strongest fit | Main trade-off |
|---|---|---|
| Sealed interface | Alternative outcomes with different data | Every permitted subtype must be maintained |
| Sealed class | Shared behaviour with restricted inheritance | Uses single-class inheritance |
| Enum | A small set of constant values | Cannot carry different data per case |
| Abstract class | Shared state with open extension | Offers no exhaustive subtype guarantee |
Selection should follow the lifecycle of the model. Use a sealed interface for alternative data shapes, a sealed class when common implementation matters, and an ordinary abstract class when independent extension is a core requirement.
Apply closed types to authentication and APIs
Authentication adapters often collapse provider responses into a small set of application decisions: authenticated, credentials rejected, locked, or step-up required. A sealed interface gives Spring Security integration code an exhaustive boundary, while each record can carry safe details without exposing the complete provider response.
The same approach works for API failures. ValidationFailure, ConflictFailure, and UnauthorizedFailure can implement ApiError, allowing a Spring MVC exception handler to map every variant to the correct HTTP status and stable error code.
Database-driven roles require a different design. Administrators can add permissions without a deployment, so a sealed UserRole would become inaccurate. Restrict sealed hierarchies to outcomes governed by application code or a stable protocol.
Where the pattern fits and where it does not
A short design review can separate strong candidates from situations needing greater runtime flexibility.
Useful candidates include:
- Payment, delivery, and subscription outcomes with known variants
- State machines with legal transitions and explicit terminal states
- Stable API error contracts that clients can handle exhaustively
- Authentication decisions with a finite set of provider responses
- Domain hierarchies owned by one team and closed to arbitrary extension
Cases that call for another design include:
- Plugins discovered or loaded at runtime
- Administrator-managed roles and permissions
- Frameworks designed for customer-supplied subclasses
- Hierarchies split across teams with changing ownership
- One-off constants better represented by an enum or value object
Integrate sealed hierarchies with Spring and persistence
In a Spring application, sealed types work well as commands, domain results, and DTOs. Controllers can accept a permitted request hierarchy, services can switch over every outcome, and Jackson can serialise records using familiar annotations.
JPA and Hibernate need more care. Entities are often made non-final to support lazy-loading proxies, so sealing an entity hierarchy can conflict with proxy generation. Closed records remain useful for DTOs and value objects, while domain services can use sealed hierarchies without becoming proxy targets.
For payment integrations, a non-sealed boundary is appropriate only where the provider genuinely permits new response types. Testing each permitted subtype and documenting why the hierarchy exists keeps the design intentional. Java sealed interfaces and classes then act as practical guardrails against incomplete handling, accidental extension, and unclear business states.