Java sealed classes, introduced in Java 17, represent a significant step forward in creating type-safe domain models. They provide a restricted inheritance hierarchy where a class or interface can explicitly control which other classes or interfaces may extend or implement them. This feature enhances type safety while maintaining flexibility in design.
I’ve been working with sealed classes since their introduction, and I’ve discovered several powerful techniques to leverage them effectively. These approaches have transformed how I model domains in my Java applications.
Domain Object Hierarchies
Sealed classes excel at modeling domain concepts with clear classifications. They create a closed set of related types that can be exhaustively processed.
public sealed interface Vehicle permits Car, Motorcycle, Truck {
String getRegistrationNumber();
int getWheelCount();
}
public final class Car implements Vehicle {
private final String registrationNumber;
private final boolean isConvertible;
private final int passengerCapacity;
public Car(String registrationNumber, boolean isConvertible, int passengerCapacity) {
this.registrationNumber = registrationNumber;
this.isConvertible = isConvertible;
this.passengerCapacity = passengerCapacity;
}
@Override
public String getRegistrationNumber() {
return registrationNumber;
}
@Override
public int getWheelCount() {
return 4;
}
public boolean isConvertible() {
return isConvertible;
}
public int getPassengerCapacity() {
return passengerCapacity;
}
}
// Similarly implement Motorcycle and Truck classes
This approach ensures that no unexpected vehicle types can appear in the system. The compiler guarantees that only explicitly permitted classes can implement the Vehicle
interface.
Exhaustive Pattern Matching
Pattern matching with sealed classes is particularly powerful because the compiler can verify that all possible subtypes are handled. This eliminates runtime surprises.
public String describeVehicle(Vehicle vehicle) {
return switch (vehicle) {
case Car car -> "Car with " + car.getPassengerCapacity() + " seats" +
(car.isConvertible() ? " (convertible)" : "");
case Motorcycle motorcycle -> "Motorcycle with " + motorcycle.getEngineCapacity() + "cc engine";
case Truck truck -> "Truck with " + truck.getCargoCapacity() + " ton capacity";
};
}
The compiler verifies that all possible vehicle types are covered. If I later add a new vehicle type to the sealed hierarchy, the compiler will flag any switch expressions that don’t handle the new type.
Combining Sealed Classes with Records
Pairing sealed classes with records creates immutable, concise domain models with strong type safety.
public sealed interface Shape permits Circle, Rectangle, Triangle {
double area();
double perimeter();
}
public record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public double perimeter() {
return 2 * Math.PI * radius;
}
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
@Override
public double perimeter() {
return 2 * (width + height);
}
}
public record Triangle(double sideA, double sideB, double sideC) implements Shape {
public Triangle {
// Validate triangle inequality theorem
if (sideA + sideB <= sideC || sideA + sideC <= sideB || sideB + sideC <= sideA) {
throw new IllegalArgumentException("Invalid triangle sides");
}
}
@Override
public double area() {
// Heron's formula
double s = (sideA + sideB + sideC) / 2;
return Math.sqrt(s * (s - sideA) * (s - sideB) * (s - sideC));
}
@Override
public double perimeter() {
return sideA + sideB + sideC;
}
}
This combination creates concise, self-validating data classes with complete type safety.
Type-Safe State Machines
Sealed classes provide an elegant way to model state machines with compile-time verification of state transitions.
public sealed interface OrderState permits PendingState, ProcessingState, ShippedState, DeliveredState, CancelledState {
OrderState nextState();
boolean canTransitionTo(Class<? extends OrderState> stateClass);
}
public final class PendingState implements OrderState {
@Override
public OrderState nextState() {
return new ProcessingState();
}
@Override
public boolean canTransitionTo(Class<? extends OrderState> stateClass) {
return stateClass == ProcessingState.class || stateClass == CancelledState.class;
}
}
public final class ProcessingState implements OrderState {
@Override
public OrderState nextState() {
return new ShippedState();
}
@Override
public boolean canTransitionTo(Class<? extends OrderState> stateClass) {
return stateClass == ShippedState.class || stateClass == CancelledState.class;
}
}
// Additional state implementations...
public class Order {
private OrderState state;
private final String orderId;
public Order(String orderId) {
this.orderId = orderId;
this.state = new PendingState();
}
public void progressToNextState() {
this.state = state.nextState();
}
public boolean canTransitionTo(OrderState newState) {
return state.canTransitionTo(newState.getClass());
}
public void transitionTo(OrderState newState) {
if (!canTransitionTo(newState)) {
throw new IllegalStateException("Cannot transition from " + state.getClass().getSimpleName() +
" to " + newState.getClass().getSimpleName());
}
this.state = newState;
}
}
This model ensures invalid state transitions are caught at compile time rather than leading to runtime errors.
API Response Modeling
Sealed classes offer a clean way to represent API responses with distinct success and error paths.
public sealed interface ApiResponse<T> permits Success, Error {
<R> ApiResponse<R> map(Function<T, R> mapper);
}
public record Success<T>(T data) implements ApiResponse<T> {
@Override
public <R> ApiResponse<R> map(Function<T, R> mapper) {
return new Success<>(mapper.apply(data));
}
}
public record Error(int code, String message) implements ApiResponse<Object> {
@Override
public <R> ApiResponse<R> map(Function<Object, R> mapper) {
return (ApiResponse<R>) this;
}
}
// Usage example
public class UserService {
public ApiResponse<User> getUserById(String id) {
try {
User user = database.findUser(id);
if (user != null) {
return new Success<>(user);
} else {
return new Error(404, "User not found");
}
} catch (Exception e) {
return new Error(500, "Server error: " + e.getMessage());
}
}
}
// Client code
ApiResponse<User> response = userService.getUserById("123");
String username = switch (response) {
case Success<User> success -> success.data().getUsername();
case Error error -> "Error: " + error.code() + " - " + error.message();
};
This approach enables fluent, type-safe handling of both success and error cases without exception handling boilerplate.
Validation Result Hierarchy
Sealed classes provide an elegant way to represent validation outcomes with detailed error information.
public sealed interface ValidationResult permits Valid, Invalid {
boolean isValid();
static ValidationResult merge(ValidationResult first, ValidationResult second) {
if (first instanceof Valid && second instanceof Valid) {
return new Valid();
}
List<String> errors = new ArrayList<>();
if (first instanceof Invalid invalid1) {
errors.addAll(invalid1.errors());
}
if (second instanceof Invalid invalid2) {
errors.addAll(invalid2.errors());
}
return new Invalid(errors);
}
}
public record Valid() implements ValidationResult {
@Override
public boolean isValid() {
return true;
}
}
public record Invalid(List<String> errors) implements ValidationResult {
@Override
public boolean isValid() {
return false;
}
public Invalid(String error) {
this(List.of(error));
}
}
public class UserValidator {
public ValidationResult validateUser(User user) {
ValidationResult nameValidation = validateName(user.getName());
ValidationResult emailValidation = validateEmail(user.getEmail());
ValidationResult ageValidation = validateAge(user.getAge());
return ValidationResult.merge(
ValidationResult.merge(nameValidation, emailValidation),
ageValidation
);
}
private ValidationResult validateName(String name) {
if (name == null || name.trim().isEmpty()) {
return new Invalid("Name cannot be empty");
}
if (name.length() < 2) {
return new Invalid("Name must be at least 2 characters");
}
return new Valid();
}
// Additional validation methods...
}
This validation pattern collects all validation errors rather than stopping at the first problem, providing comprehensive feedback.
Event Representation
Sealed classes excel at modeling domain events with precise type hierarchies.
public sealed interface DomainEvent permits UserEvent, OrderEvent {
UUID entityId();
Instant timestamp();
}
public sealed interface UserEvent extends DomainEvent permits UserCreated, UserUpdated, UserDeleted {
// User-specific event methods could go here
}
public record UserCreated(UUID entityId, String username, String email, Instant timestamp)
implements UserEvent {}
public record UserUpdated(UUID entityId, Map<String, Object> changedFields, Instant timestamp)
implements UserEvent {}
public record UserDeleted(UUID entityId, String reason, Instant timestamp)
implements UserEvent {}
public sealed interface OrderEvent extends DomainEvent permits OrderPlaced, OrderShipped, OrderCancelled {
// Order-specific event methods could go here
}
// Order event implementations...
public class EventProcessor {
public void process(DomainEvent event) {
switch (event) {
case UserCreated uc -> handleUserCreated(uc);
case UserUpdated uu -> handleUserUpdated(uu);
case UserDeleted ud -> handleUserDeleted(ud);
case OrderEvent oe -> handleOrderEvent(oe);
}
}
private void handleOrderEvent(OrderEvent event) {
switch (event) {
case OrderPlaced op -> processOrderPlaced(op);
case OrderShipped os -> processOrderShipped(os);
case OrderCancelled oc -> processOrderCancelled(oc);
}
}
// Handler implementations...
}
This approach enables nested pattern matching with full type safety at each level of the event hierarchy.
Visitor Pattern Implementation
Sealed classes pair beautifully with the Visitor pattern for type-safe operations on complex object structures.
public sealed interface JsonNode permits JsonObject, JsonArray, JsonValue {
<T> T accept(JsonVisitor<T> visitor);
}
public final class JsonObject implements JsonNode {
private final Map<String, JsonNode> properties;
public JsonObject(Map<String, JsonNode> properties) {
this.properties = Map.copyOf(properties);
}
public JsonNode get(String property) {
return properties.get(property);
}
public Map<String, JsonNode> getProperties() {
return properties;
}
@Override
public <T> T accept(JsonVisitor<T> visitor) {
return visitor.visitObject(this);
}
}
public final class JsonArray implements JsonNode {
private final List<JsonNode> elements;
public JsonArray(List<JsonNode> elements) {
this.elements = List.copyOf(elements);
}
public List<JsonNode> getElements() {
return elements;
}
@Override
public <T> T accept(JsonVisitor<T> visitor) {
return visitor.visitArray(this);
}
}
public sealed interface JsonValue extends JsonNode
permits JsonString, JsonNumber, JsonBoolean, JsonNull {
}
// Value implementations...
public interface JsonVisitor<T> {
T visitObject(JsonObject object);
T visitArray(JsonArray array);
T visitString(JsonString string);
T visitNumber(JsonNumber number);
T visitBoolean(JsonBoolean bool);
T visitNull(JsonNull nullNode);
}
// Example visitor
public class JsonPrettyPrinter implements JsonVisitor<String> {
private static final String INDENT = " ";
public String print(JsonNode node) {
return node.accept(this);
}
@Override
public String visitObject(JsonObject object) {
if (object.getProperties().isEmpty()) {
return "{}";
}
StringBuilder sb = new StringBuilder("{\n");
Iterator<Map.Entry<String, JsonNode>> it = object.getProperties().entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, JsonNode> entry = it.next();
sb.append(INDENT).append("\"").append(entry.getKey()).append("\": ");
String value = entry.getValue().accept(this);
sb.append(value.replace("\n", "\n" + INDENT));
if (it.hasNext()) {
sb.append(",");
}
sb.append("\n");
}
return sb.append("}").toString();
}
// Other visitor methods...
}
This implementation ensures that all JSON node types are handled correctly, with compiler verification that no case is missed.
In my experience, sealed classes provide the perfect balance between the open-closed principle and type safety. They allow me to define restricted type hierarchies that can be processed exhaustively at compile time.
When I design domain models now, I frequently start by identifying the closed sets of related types that would benefit from sealing. This approach has significantly reduced bugs in my code, especially those caused by missing cases in type handling.
The combination of sealed classes with pattern matching has been particularly revolutionary for my error handling strategies. I can now model error cases explicitly rather than relying on exceptions, making the flow of control much clearer and more predictable.
As Java continues to evolve, sealed classes stand out as one of the most valuable additions for domain modeling. They enable a more functional style of programming while maintaining the type safety and clarity that Java developers value. By incorporating these techniques into your codebase, you’ll create more robust, maintainable systems with fewer bugs and clearer semantics.