java

Java Sealed Classes: 7 Powerful Techniques for Domain Modeling in Java 17

Discover how Java sealed classes enhance domain modeling with 7 powerful patterns. Learn to create type-safe hierarchies, exhaustive pattern matching, and elegant state machines for cleaner, more robust code. Click for practical examples.

Java Sealed Classes: 7 Powerful Techniques for Domain Modeling in Java 17

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.

Keywords: java sealed classes, java 17 features, sealed interfaces in java, java restricted inheritance, java domain modeling, java pattern matching, exhaustive pattern matching java, sealed classes with records, type-safe java code, java state machine implementation, java api response modeling, java validation patterns, domain event modeling, visitor pattern java, java switch expressions, java hierarchy restrictions, java type safety, java object-oriented design, java 17 sealed classes examples, java closed type hierarchies, compile-time type checking java, java domain-driven design, advanced java features, java sealed permits keyword, modern java programming techniques



Similar Posts
Blog Image
Advanced Java Stream API Techniques: Boost Data Processing Efficiency

Discover 6 advanced Java Stream API techniques to boost data processing efficiency. Learn custom collectors, parallel streams, and more for cleaner, faster code. #JavaProgramming #StreamAPI

Blog Image
7 Essential Java Interface Design Patterns for Clean Code: Expert Guide with Examples

Learn essential Java interface design patterns with practical examples and code snippets. Master Interface Segregation, Default Methods, Bridge Pattern, and more for building maintainable applications.

Blog Image
When Networks Attack: Crafting Resilient Java Apps with Toxiproxy and Friends

Embrace Network Anarchy: Mastering Java App Resilience with Mockito, JUnit, Docker, and Toxiproxy in a Brave New World

Blog Image
Master Multi-Tenant SaaS with Spring Boot and Hibernate

Streamlining Multi-Tenant SaaS with Spring Boot and Hibernate: A Real-World Exploration

Blog Image
Java Records: 7 Optimization Techniques for Better Performance and Code Clarity

Discover 6 expert optimization techniques for Java Records that boost application performance. Learn how to enhance your data-centric code with immutability handling, custom accessors, and more proven patterns from production environments. Code examples included.

Blog Image
6 Essential Design Patterns for Scalable Java Microservices: A Developer's Guide

Discover 6 key design patterns for building scalable Java microservices. Learn how to implement Aggregator, API Gateway, Circuit Breaker, CQRS, Event Sourcing, and Saga patterns with code examples.