Modern Java Features: Sealed Classes

Sealing a class or interface restricts the list of subclasses or implementations to a predefined set. For example, consider defining a Shape and restricting subclasses to be only triangles and squares.

public class SealedClasses {
    sealed class Shape permits Triangle, Square {
    }
}
public final class Triangle extends Shape {
}
public non-sealed class Square extends Shape {
}

Did you notice the use of final and non-sealed keywords here?
Subclasses of a sealed class must specify whether they are final, non-sealed, or sealed. This is because the compiler needs full visibility of the class hierarchy to enforce the restriction.

Sealed types were introduced in Java 15 as a preview feature, became standard in Java 17, and were further enhanced in Java 21 with pattern-matching support in switch expressions — making them even more ergonomic to use.

💳 Modeling Business Constraints with Sealed Classes

Why would you want to seal a class or interface — and does it violate the Open-Closed Principle?

Short answer: Not necessarily — but they do limit extension intentionally.

The Open-Closed Principle says:

"Software entities should be open for extension, but closed for modification."

With sealed classes, you're closing the inheritance hierarchy by design. That might look like it contradicts OCP, but in fact:

  • You’re explicitly modeling a closed set of variants.

  • You’re still open to extension — just within a bounded and intentional scope.

For example, think of a system that has 3 well-defined payment methods. You want to model the business domain closely and prevent maintainers from adding a new payment method inadvertently. A good way to do this is by sealing the PaymentMethod class and having the compiler error when an invalid subclass is created:

// File: PaymentMethod.java
public sealed interface PaymentMethod
    permits CreditCard, PayPal, BankTransfer {

    String processPayment(double amount);
}
// File: CreditCard.java
public final class CreditCard implements PaymentMethod {
    @Override
    public String processPayment(double amount) {
        return "Processed $" + amount + " with Credit Card.";
    }
}
// File: PayPal.java
public final class PayPal implements PaymentMethod {
    @Override
    public String processPayment(double amount) {
        return "Processed $" + amount + " with PayPal.";
    }
}
// File: BankTransfer.java
public final class BankTransfer implements PaymentMethod {
    @Override
    public String processPayment(double amount) {
        return "Processed $" + amount + " with Bank Transfer.";
    }
}
//Doesn't compile: The type CryptoPayment should be a permitted subtype of PaymentMethod
final public class CryptoPayment implements PaymentMethod {

  @Override
  public String processPayment(double amount) {
    // TODO Auto-generated method stub
    return null;
  }
}

💡 Leverage Pattern Matching with Sealed Classes

When you know all the possible subclasses ahead of time (thanks to sealed), the Java compiler can enforce exhaustive handling in switch expressions — a big win for correctness and readability.

public class BankInterestExample {
    public static void main(String[] args) {
        BankAccount savings = new SavingsAccount(10000);
        BankAccount fixed = new FixedDepositAccount(15000);
        BankAccount checking = new CheckingAccount(5000);

        System.out.println(calculateInterest(savings));   // 300.0
        System.out.println(calculateInterest(fixed));     // 750.0
        System.out.println(calculateInterest(checking));  // 0.0
    }

    sealed interface BankAccount permits SavingsAccount, FixedDepositAccount, CheckingAccount {
        double balance();
    }

    //note: defined as record to provide a simple and short example
    record SavingsAccount(double balance) implements BankAccount {}
    record FixedDepositAccount(double balance) implements BankAccount {}
    record CheckingAccount(double balance) implements BankAccount {}

    static double calculateInterest(BankAccount account) {
        return switch (account) {
            case SavingsAccount sa       -> sa.balance() * 0.03;
            case FixedDepositAccount fda -> fda.balance() * 0.05;
            case CheckingAccount ca      -> 0.0;
        };
    }
}

📌 Compiler rules to keep in mind:

  • You can define permitted subclasses in the same file as the sealed class. If you do so, then you can omit the permits clause.

  • The entire hierarchy must be accessible by the sealed class at compile time. Meaning, subclasses and subclasses of subclasses if they are sealed as well.

  • Subclasses must directly extend the sealed class.

  • Subclasses must have exactly one of the following modifiers to describe how it continues the sealing initiated by its superclass: final, non-sealed, sealed.

  • You can name a record class in the permits clause but given record classes are final you don’t need to declare it as final explicitly.

  • Subclasses must be in the same module as the sealed class (if the sealed class is in a named module) or in the same package (if the sealed class is in the unnamed module).

Java 17+ introduces two helpful APIs in java.lang.Class:

  • boolean isSealed() — returns true if the class or interface is sealed.

  • ClassDesc[] permittedSubclasses() — returns the permitted subclass descriptors (or empty if not sealed).

Summary

Sealed classes give you control over inheritance, helping you model your domain safely and predictably.
Use them when:

  • You want to model finite sets of domain types (e.g., payment types).

  • You want to leverage exhaustiveness in pattern matching.

  • You want to prevent unauthorized subclassing.

💬 What Do You Think?

Have you started using sealed classes? Do you think they are a great idea? Let me know in the comments.

📚 Want to Learn More?

I found the following resources helpful:

0
Subscribe to my newsletter

Read articles from Mirna De Jesus Cambero directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mirna De Jesus Cambero
Mirna De Jesus Cambero

I’m a backend software engineer with over a decade of experience primarily in Java. I started this blog to share what I’ve learned in a simplified, approachable way — and to add value for fellow developers. Though I’m an introvert, I’ve chosen to put myself out there to encourage more women to explore and thrive in tech. I believe that by sharing what we know, we learn twice as much — that’s precisely why I’m here.