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.

* **Immutability by Design:** Fields are `final`. Accessors are provided.

* **Transparent Data:** Unlike traditional Beans, Records "are" their data. The JVM can optimize them more aggressively (e.g., potential stack allocation in future versions).

```java

// 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.

* **Restricted Hierarchy:** The `permits` clause closes the hierarchy.

* **Exhaustiveness:** The compiler can verify that every possible subtype is handled in a `switch` expression.

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

```java

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)

```java

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**.

```java

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.

```java

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:**

- [Java 21 Features](JavaTwentyOneFeatures) — Broader context on pattern matching.

- [Immutable Data Patterns](ImmutableDataPatterns) — Why ADTs are the foundation of safe systems.

- [Design Patterns Hub](DesignPatternsHub) — Modernizing GOF patterns with ADTs.