Project: Modeling a Domain
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 anOrderStatus. - PaymentResult — a sealed interface with three record implementations:
Success,Declined, andError. - OrderProcessor — a class that ties the pieces together, using pattern matching to handle
PaymentResult.
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.
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.
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.
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.
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.
Step 6 — Putting It All Together
What Makes This Design Good?
- Invalid states are unrepresentable. You cannot create an
OrderItemwith a negative quantity;Orderrefuses empty item lists;canTransitionTorejects illegal status jumps. - The compiler checks completeness. If you add a new
PaymentResultvariant, theswitchinOrderProcessorwill not compile until you handle it. - Data and behaviour stay together.
lineTotal()lives onOrderItem;total()lives onOrder; transition rules live onOrderStatus. - Immutability everywhere. Every state change produces a new object — no hidden mutation bugs.
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.