Java ADTs: Records and Sealed Classes

The combination of Records (Product Types) and Sealed Classes (Sum Types) allows Java to support formal Algebraic Data Types (ADTs). This shift enables developers to move domain invariants into the type system, replacing runtime checks with compile-time safety.

I. Product Types: Records

A Record is a Product Type because its state space is the product of its components.

// Product Type: State space = int * String
public record User(int id, String name) {}

II. Sum Types: Sealed Classes

A Sealed class/interface is a Sum Type because its state space is the sum of its permitted subtypes.

III. Concrete Pattern: The Domain State Machine

Encoding an order lifecycle as an ADT prevents invalid states (e.g., a "Cancelled" order having a "TrackingNumber").

The Model

public sealed interface OrderState 
    permits Created, Shipped, Delivered, Cancelled {}

public record Created(Instant timestamp) implements OrderState {}

public record Shipped(Instant timestamp, String trackingId) implements OrderState {}

public record Delivered(Instant timestamp, String signedBy) implements OrderState {}

public record Cancelled(Instant timestamp, String reason) implements OrderState {}

Exhaustive Processing (Java 21)

public String getDisplayStatus(OrderState state) {
    return switch (state) {
        case Created c -> "Order placed at " + c.timestamp();
        case Shipped s -> "In transit. Tracking: " + s.trackingId();
        case Delivered d -> "Delivered to " + d.signedBy();
        case Cancelled c -> "Cancelled: " + c.reason();
        // No 'default' needed! Adding a new state will break compilation here.
    };
}

IV. Record Deconstruction and Guards

Java 21 allows deconstructing records directly in the case label, including When Guards.

public void process(OrderState state) {
    switch (state) {
        case Shipped(var ts, var id) when id.startsWith("FEDEX") -> 
            trackViaFedex(id);
        case Shipped(var ts, var id) -> 
            trackGeneric(id);
        default -> {}
    }
}

V. Strategic Guidelines

  1. Prefer ADTs over the Visitor Pattern: The Visitor Pattern was a workaround for the lack of sum types. Sealed + Switch is more readable and less boilerplate-intensive.
  2. Compact Constructors for Invariants: Use the compact constructor to enforce business rules.
    public record PositiveAmount(double value) {
        public PositiveAmount {
            if (value <= 0) throw new IllegalArgumentException();
        }
    }
    
  3. Records are NOT Entities: Do not use Records as JPA Entities. JPA requires a no-arg constructor and mutable fields. Use Records for DTOs and value objects.

See Also: