Enums, Records & Sealed Types

Project: Modeling a Domain

15 min Lesson 10 of 13

Project: Modeling a Domain

The best way to cement your understanding of enums, records, and sealed types is to use them together on a real problem. In this lesson we model an e-commerce order domain: an order has a status lifecycle, its items are immutable value objects, and each payment result is one of a fixed set of outcomes. By the end you will have a small, self-contained domain that is correct by construction — invalid states are literally unrepresentable.

The Domain at a Glance

  • OrderStatus — an enum that drives the order lifecycle (PENDING → CONFIRMED → SHIPPED → DELIVERED, or CANCELLED).
  • OrderItem — a record representing one line item on an order (product name, quantity, unit price).
  • Order — a record that aggregates a list of OrderItems and an OrderStatus.
  • PaymentResult — a sealed interface with three record implementations: Success, Declined, and Error.
  • OrderProcessor — a class that ties the pieces together, using pattern matching to handle PaymentResult.
Why model this way? Each type carries exactly the data it needs and nothing else. A Success carries a transaction ID; a Declined carries a reason code; an Error carries an exception message. You cannot confuse one for another at compile time.

Step 1 — The Order Lifecycle Enum

An enum makes the lifecycle explicit. We also add a helper method canTransitionTo so the business rule lives in one place, not scattered across if-chains.

public enum OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED; public boolean canTransitionTo(OrderStatus next) { return switch (this) { case PENDING -> next == CONFIRMED || next == CANCELLED; case CONFIRMED -> next == SHIPPED || next == CANCELLED; case SHIPPED -> next == DELIVERED; case DELIVERED, CANCELLED -> false; // terminal states }; } }

Because switch on an enum is exhaustive (the compiler checks it), adding a new status later will cause a compile error here — exactly where the business logic belongs.

Step 2 — The OrderItem Record

An order line is a pure value: same product name, same quantity, same price means the same item. A record gives us that equality, immutability, and a compact declaration for free.

import java.math.BigDecimal; public record OrderItem(String productName, int quantity, BigDecimal unitPrice) { // Compact constructor — validate on construction public OrderItem { if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive"); if (unitPrice.signum() < 0) throw new IllegalArgumentException("Unit price cannot be negative"); } public BigDecimal lineTotal() { return unitPrice.multiply(BigDecimal.valueOf(quantity)); } }
Use BigDecimal for money. double and float cannot represent most decimal fractions exactly. A floating-point price calculation will eventually produce results like 9.999999999 instead of 10.00 — use BigDecimal in any financial domain.

Step 3 — The Order Record

The Order record wraps a list of items and the current status. It derives the total from its items and offers a method to advance the lifecycle.

import java.math.BigDecimal; import java.util.List; public record Order(String id, List<OrderItem> items, OrderStatus status) { public Order { if (items == null || items.isEmpty()) throw new IllegalArgumentException("An order must have at least one item"); items = List.copyOf(items); // defensive copy — records should be truly immutable } public BigDecimal total() { return items.stream() .map(OrderItem::lineTotal) .reduce(BigDecimal.ZERO, BigDecimal::add); } /** Returns a new Order with the updated status, or throws if the transition is illegal. */ public Order withStatus(OrderStatus next) { if (!status.canTransitionTo(next)) throw new IllegalStateException( "Cannot transition from " + status + " to " + next); return new Order(id, items, next); } }

Notice that withStatus returns a new Order rather than mutating the existing one. Records reinforce immutability; every state change produces a fresh value.

Step 4 — The Sealed PaymentResult

Payment outcomes form a closed set. A sealed interface lets the compiler know every possible case, and each case carries different data.

public sealed interface PaymentResult permits PaymentResult.Success, PaymentResult.Declined, PaymentResult.Error { record Success(String transactionId) implements PaymentResult {} record Declined(String reasonCode) implements PaymentResult {} record Error(String message) implements PaymentResult {} }
Nesting the records inside the sealed interface keeps the namespace tidy. You reference them as PaymentResult.Success, which reads like a sentence: "the payment result is a success."

Step 5 — The OrderProcessor

The processor charges the customer and, depending on the payment result, advances or cancels the order. Pattern matching on switch handles every variant without casting.

public class OrderProcessor { /** Simulate a payment gateway and return the updated order. */ public Order process(Order order, PaymentResult result) { return switch (result) { case PaymentResult.Success s -> { System.out.println("Payment OK — txn " + s.transactionId()); yield order.withStatus(OrderStatus.CONFIRMED); } case PaymentResult.Declined d -> { System.out.println("Payment declined: " + d.reasonCode()); yield order.withStatus(OrderStatus.CANCELLED); } case PaymentResult.Error e -> { System.out.println("Gateway error: " + e.message()); yield order; // leave status unchanged; retry later } }; } }

Step 6 — Putting It All Together

import java.math.BigDecimal; import java.util.List; public class Main { public static void main(String[] args) { var item1 = new OrderItem("Wireless Keyboard", 1, new BigDecimal("49.99")); var item2 = new OrderItem("USB Hub", 2, new BigDecimal("19.99")); var order = new Order("ORD-001", List.of(item1, item2), OrderStatus.PENDING); System.out.println("Total: " + order.total()); // 89.97 var processor = new OrderProcessor(); // Happy path var confirmed = processor.process(order, new PaymentResult.Success("TXN-XYZ")); System.out.println("Status: " + confirmed.status()); // CONFIRMED // Decline path var cancelled = processor.process(order, new PaymentResult.Declined("INSUFFICIENT_FUNDS")); System.out.println("Status: " + cancelled.status()); // CANCELLED } }

What Makes This Design Good?

  • Invalid states are unrepresentable. You cannot create an OrderItem with a negative quantity; Order refuses empty item lists; canTransitionTo rejects illegal status jumps.
  • The compiler checks completeness. If you add a new PaymentResult variant, the switch in OrderProcessor will not compile until you handle it.
  • Data and behaviour stay together. lineTotal() lives on OrderItem; total() lives on Order; transition rules live on OrderStatus.
  • Immutability everywhere. Every state change produces a new object — no hidden mutation bugs.
Do not overuse records for entities with identity. Records are value objects. An Order record works here because we treat orders as immutable snapshots. If you need JPA-managed entities with mutable fields, use a regular class. Mix the two: use records for value objects (money, addresses, results) and regular classes for mutable entities.

Summary

In this capstone lesson you combined all three features — enums, records, and sealed types — into a cohesive domain model. The result is expressive, concise, and hard to misuse: the type system enforces the business rules so you do not have to write defensive null-checks and status-string comparisons throughout your codebase. That is the payoff for learning these modern Java features.