Java sealed classes, introduced as a preview feature and finalized in Java 17, offer precise control over inheritance hierarchies. They let us define which classes can extend or implement a type, preventing arbitrary inheritance. This technique enhances domain modeling by making hierarchies explicit and compiler-verifiable. I’ve found this particularly valuable when designing APIs or domain-specific rules where unchecked inheritance could lead to fragility.
Basic Sealed Class Declaration
Declaring a sealed class requires explicitly listing permitted subclasses. This creates a bounded inheritance model. For example, in payment processing systems, I restrict payment methods to known types:
public sealed class PaymentMethod permits CreditCard, PayPal, BankTransfer {
public abstract void process();
}
public final class CreditCard extends PaymentMethod {
@Override public void process() {
System.out.println("Processing credit card");
}
}
// Compile-time error if unpermitted subclass attempted
The compiler enforces that only CreditCard
, PayPal
, and BankTransfer
can extend PaymentMethod
. This eliminates accidental extensions and clarifies valid variants.
Sealed Interfaces with Records
Sealed interfaces pair well with records for concise data hierarchies. When modeling log entries:
public sealed interface LogEntry permits SystemLog, UserLog, AuditLog {
String getMessage();
}
public record SystemLog(String component, String message) implements LogEntry {
public String getMessage() { return "[" + component + "] " + message; }
}
public record UserLog(String userId, String action) implements LogEntry {
public String getMessage() { return userId + " performed: " + action; }
}
Here, records implement the interface cleanly while the sealed constraint ensures no unexpected log types appear later.
Local Permitted Subclasses
For self-contained hierarchies, nest permitted subclasses within the sealed parent:
public sealed abstract class FileFormat {
public static final class CSV extends FileFormat {
public void parse() { /* CSV logic */ }
}
public static final class JSON extends FileFormat {
public void parse() { /* JSON logic */ }
}
private FileFormat() {} // Block external inheritance
}
// Usage: only CSV/JSON allowed
FileFormat format = new FileFormat.CSV();
The private constructor prevents extension outside the class. I use this for configuration objects where variants are fixed.
Controlled Openness with Non-Sealed
Use non-sealed
for partial hierarchy openness. In device modeling:
public sealed class Device permits Mobile, Computer {
public abstract String getOS();
}
public non-sealed abstract class Mobile extends Device {}
public final class Android extends Mobile {
@Override public String getOS() { return "Android"; }
}
public final class IOS extends Mobile {
@Override public String getOS() { return "iOS"; }
}
Mobile
is non-sealed, allowing Android
and IOS
extensions. Computer
remains sealed. This balances flexibility with constraint.
Exhaustive Pattern Matching
Sealed classes enable compiler-checked exhaustiveness in pattern matching. For geometry calculations:
public sealed interface Shape permits Circle, Rectangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public double calculateArea(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
}
If I add a Triangle
to permits
, the switch
immediately fails compilation until I handle the new case. This catches errors early.
Sealed Records for Data Trees
Model recursive structures like trees with sealed records:
public sealed interface Tree<T> permits Leaf, Node {}
public record Leaf<T>(T value) implements Tree<T> {}
public record Node<T>(Tree<T> left, Tree<T> right) implements Tree<T> {}
// Build a tree: Node(Leaf(1), Node(Leaf(2), Leaf(3)))
Tree<Integer> tree = new Node<>(new Leaf<>(1), new Node<>(new Leaf<>(2), new Leaf<>(3)));
Records provide immutable data carriers while the sealed hierarchy guarantees only Leaf
and Node
exist. I use this for ASTs in compilers.
Factory Methods with Sealed Results
Combine static factories with sealed classes for expressive APIs:
public sealed class Result<T> permits Result.Success, Result.Failure {
public static final class Success<T> extends Result<T> {
private final T value;
Success(T value) { this.value = value; }
}
public static final class Failure<T> extends Result<T> {
private final Exception error;
Failure(Exception error) { this.error = error; }
}
public static <T> Result<T> success(T value) {
return new Success<>(value);
}
public static <T> Result<T> failure(Exception e) {
return new Failure<>(e);
}
}
// Usage: clear success/failure paths
Result<String> data = Result.success("Processed");
Clients handle success/failure without worrying about unknown subtypes.
Reflection for Runtime Checks
Inspect sealed hierarchies at runtime using reflection:
public void validatePlugin(Class<?> pluginClass) {
if (pluginClass.isSealed()) {
Class<?>[] permitted = pluginClass.getPermittedSubclasses();
System.out.println("Allowed plugins: " + Arrays.toString(permitted));
}
}
// Check LogEntry from earlier:
validatePlugin(LogEntry.class); // Prints: [SystemLog, UserLog, AuditLog]
This helps in frameworks that dynamically load modules, ensuring only permitted types are instantiated.
Framework Integration
Enforce plugin contracts in extensible systems:
public sealed interface Plugin permits DatabasePlugin, AuthPlugin {
void initialize();
}
public final class DatabasePlugin implements Plugin {
@Override public void initialize() { connectToDB(); }
}
public Plugin loadPlugin(String type) {
return switch (type) {
case "db" -> new DatabasePlugin();
case "auth" -> new AuthPlugin();
default -> throw new IllegalArgumentException("Invalid plugin");
};
}
The sealed interface acts as a “gatekeeper,” preventing invalid plugins from being loaded.
Migrating Final Classes
Gradually relax overly strict hierarchies:
// Before: closed for extension
public final class Cat extends Animal {}
public final class Dog extends Animal {}
// After: allow controlled extension
public sealed class Animal permits Cat, Dog, Bird {}
public non-sealed class Bird extends Animal {}
public final class Parrot extends Bird {} // Now permitted
Existing Cat
/Dog
remain final, while Bird
opens for specialization like Parrot
. This maintains backward compatibility.
Java sealed classes bring intentionality to inheritance. By declaring permitted subtypes upfront, we create self-documenting hierarchies. The compiler becomes an ally, verifying exhaustiveness in pattern matching and blocking unauthorized extensions. For domain modeling, this means business rules like “only these payment methods are allowed” become explicit in code. Performance-wise, sealed classes enable efficient pattern matching as the JVM can optimize based on known subtypes. When designing APIs, I use them to prevent client misuse while retaining evolution paths through non-sealed
branches. They turn inheritance from a potential maintenance liability into a structured design tool.