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:

Cases that call for another design include:

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.