Turning Business Concepts into Domain Models
From business language to Java objects that own their state, rules and behaviour.
You are viewing a free preview lesson.
So far, we have reasoned about the Order Management Backend before writing business code. We know the core concepts (Product, Inventory, Order, OrderItem, customer identity and Payment) and what each owns: a Product its price and active state, Inventory a non-negative quantity, an Order its customer, OrderItems and lifecycle. Now we reach the next question:
How do we represent these business concepts as Java objects without losing their meaning?
This lesson is not about JPA, and we are not designing database mappings yet. The goal is to represent business concepts, state, rules and behaviour in Java so that the code preserves the meaning of the domain.
Domain Modeling Is Not Table Design
A common mistake is to start with the tables (products, inventory, orders, order_items), create ProductEntity, InventoryEntity, OrderEntity and OrderItemEntity, and call the domain model finished. But database design answers how data is persisted; domain modeling answers what a concept means, what state it owns, and what rules keep that state valid. Related concerns, not the same concern.
Start With Business Language
Consider the requirement "A customer can cancel an eligible UNPAID Order". If our model exposes only order.setStatus(OrderStatus.CANCELLED), we can change data, but the code assigns a field rather than expressing the business operation. With order.cancel(), the code says what the business says, and Order can decide whether cancellation is valid. A meaningful domain model makes business behaviour visible in code.
Data Holder vs Domain Model
Consider this class:
public class Order {
private String status;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
It holds data, but callers can write order.setStatus("BANANA"), or set "PAID" and then "CANCELLED", without respecting the Order lifecycle. The object does not protect its own meaning. A better direction moves the rules into the object: cancel() and markPaid() check that the Order is still UNPAID before changing its status, and reject the change otherwise. We build that class below. The important improvement is not the exception it throws; it is responsibility.
Questions to Ask When Modeling a Domain Concept
For each business concept, ask:
- What identifies it? What state does it own, and what does it not own?
- What rules must always hold, and can callers bypass them?
- Which operations meaningfully change its state?
- Which rules can it decide alone, and which need other concepts?
- Does it depend on Spring, HTTP or persistence, or can plain Java test it?
- Is this a confirmed requirement or an imagined future feature?
They matter far more than "Which JPA annotations does this class need?"
Product
Product has a stable identity, a name, a current price and an active state. It can be created, updated and deactivated, and an inactive Product cannot be used for a new Order. It does not own the Inventory quantity, the historical OrderItem price or the Order lifecycle. Its identity is the established UUID-backed ProductId, which makes an identifier's meaning explicit:
package com.tarkev.ordermanagement.product.domain;
import java.util.Objects;
import java.util.UUID;
public record ProductId(UUID value) {
public ProductId {
Objects.requireNonNull(value, "value");
}
public static ProductId newId() {
return new ProductId(UUID.randomUUID());
}
}
The price is a long priceCents in minor units (8990 means 89.90). The ID must exist, the name must be valid and priceCents >= 0. A price of -500 must be impossible, so this rule cannot live only at the HTTP boundary: Product protects it whenever its state is created or changed. Zero stays valid, and we invent no price limits, discounts or currency rules without requirements.
A new Product always begins active, so creation is a named factory method: creating a new Product differs from reconstructing one from persistence, a distinction we return to with Order. Instead of setPriceCents(...) and setActive(false), Product offers changePrice(...) and deactivate(), the natural places to preserve invariants:
package com.tarkev.ordermanagement.product.domain;
import java.util.Objects;
public final class Product {
private final ProductId id;
private String name;
private long priceCents;
private boolean active;
private Product(
ProductId id, String name, long priceCents, boolean active) {
this.id = Objects.requireNonNull(id, "id");
this.name = requireName(name);
this.priceCents = requireValidPrice(priceCents);
this.active = active;
}
public static Product create(
ProductId id, String name, long priceCents) {
return new Product(id, name, priceCents, true);
}
public void changePrice(long newPriceCents) {
this.priceCents = requireValidPrice(newPriceCents);
}
public void deactivate() {
this.active = false;
}
public ProductId id() { return id; }
public String name() { return name; }
public long priceCents() { return priceCents; }
public boolean isActive() { return active; }
private static String requireName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Product name is required.");
}
return name;
}
private static long requireValidPrice(long priceCents) {
if (priceCents < 0) {
throw new IllegalArgumentException("Price cannot be negative.");
}
return priceCents;
}
}
Deactivation is intentionally simple: repeating it is harmless, and v1 has no reactivation workflow.
Product.active and Inventory.availableQuantity are different facts: an active Product with quantity 0 does not appear in customer browsing. So Product answers isActive(), not an ambiguous isAvailable(); lesson 4.04 returns to this.
Inventory
Inventory owns the availableQuantity of one Product, identified by that ProductId (there is no InventoryId in v1), with the invariant availableQuantity >= 0. It stays separate from Product because it has its own lifecycle, mutation patterns and concurrency concerns.
Our v1 administration sets the exact current available quantity, so setAvailableQuantity(...) is the domain meaning of SetInventoryUseCase, not a delta-based stock-adjustment system. Cancellation restores consumed Inventory through increase(...), and at the object level Inventory can also express decrease(...):
package com.tarkev.ordermanagement.inventory.domain;
import com.tarkev.ordermanagement.product.domain.ProductId;
import java.util.Objects;
public final class Inventory {
private final ProductId productId;
private int availableQuantity;
private Inventory(ProductId productId, int availableQuantity) {
this.productId = Objects.requireNonNull(productId, "productId");
this.availableQuantity = requireNotNegative(availableQuantity);
}
public static Inventory create(ProductId productId, int quantity) {
return new Inventory(productId, quantity);
}
public void setAvailableQuantity(int quantity) {
this.availableQuantity = requireNotNegative(quantity);
}
public void increase(int amount) {
requirePositive(amount);
availableQuantity = Math.addExact(availableQuantity, amount);
}
public void decrease(int amount) {
requirePositive(amount);
if (amount > availableQuantity) {
throw new InsufficientInventory();
}
availableQuantity -= amount;
}
public ProductId productId() { return productId; }
public int availableQuantity() { return availableQuantity; }
private static int requireNotNegative(int quantity) {
if (quantity < 0) {
throw new IllegalArgumentException(
"Available quantity cannot be negative.");
}
return quantity;
}
private static void requirePositive(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive.");
}
}
}
Domain Validation Does Not Solve Database Concurrency
This models the local invariant correctly, with one important limitation. Suppose availableQuantity = 1 and two database transactions load that state: each in-memory Inventory sees 1 and passes the check, so inventory.decrease(1) does not prevent concurrent overselling. ADR-005 already resolved this: Order-time consumption uses an atomic conditional PostgreSQL UPDATE, which the repository exposes as consumeIfAvailable(ProductId, quantity) and lesson 7.08 builds.
The domain describes valid Inventory state. The persistence boundary protects Inventory correctness under concurrent database access.
Order: Identity, Owner and States
Order owns its OrderId, its owner's CustomerId, an OrderStatus, createdAt, its OrderItems and a consistent total. OrderId is UUID-backed, generated by the application rather than the database:
package com.tarkev.ordermanagement.order.domain;
import java.util.Objects;
import java.util.UUID;
public record OrderId(UUID value) {
public OrderId {
Objects.requireNonNull(value, "value");
}
public static OrderId newId() {
return new OrderId(UUID.randomUUID());
}
}
Authentication is externally owned, so ownership uses a stable, non-blank, String-backed CustomerId; we do not assume the external identity is a UUID. It lives in order/domain/ for now, and there is no Customer aggregate, CustomerRepository, registration or profile:
package com.tarkev.ordermanagement.order.domain;
public record CustomerId(String value) {
public CustomerId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("CustomerId cannot be blank.");
}
}
}
The v1 states are exactly UNPAID, PAID and CANCELLED. The allowed transitions are UNPAID → PAID and UNPAID → CANCELLED; PAID and CANCELLED are terminal, so PAID → CANCELLED and CANCELLED → PAID are invalid. We do not add PROCESSING, SHIPPED, DELIVERED, RETURNED or REFUNDED: a domain model represents confirmed requirements, not imagined future commerce functionality.
package com.tarkev.ordermanagement.order.domain;
public enum OrderStatus {
UNPAID, PAID, CANCELLED
}
OrderItem and Historical Price
Each OrderItem owns a ProductId, a quantity and the purchase-time unitPriceCents, and is immutable, matching the historical nature of an Order line. There is no OrderItemId: an OrderItem belongs to one Order, and since a Product appears at most once per Order, its eventual persistence identity is (order_id, product_id).
unitPriceCents means the Product price used when this Order was successfully created, so changing a Product's price never rewrites Order history (lesson 4.06 develops this).
OrderItem protects quantity > 0 and unitPriceCents >= 0 at construction and derives its line total with exact arithmetic, so a result beyond long throws instead of silently overflowing:
package com.tarkev.ordermanagement.order.domain;
import com.tarkev.ordermanagement.product.domain.ProductId;
import java.util.Objects;
public record OrderItem(
ProductId productId, int quantity, long unitPriceCents) {
public OrderItem {
Objects.requireNonNull(productId, "Product ID is required.");
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive.");
}
if (unitPriceCents < 0) {
throw new IllegalArgumentException(
"Unit price cannot be negative.");
}
}
public long totalCents() {
return Math.multiplyExact(unitPriceCents, quantity);
}
}
OrderItem refers to a Product through its ProductId. Order does not own the live Product, so a stable reference is enough.
Order Owns Its Items
Order and OrderItem form a strong composition: an OrderItem is not an independent resource in v1 (there is no POST /order-items), and its lifecycle belongs to the Order. So callers must not mutate the internal collection; if order.items().clear() worked, it would create an invalid zero-item Order. Order keeps an unmodifiable copy of the incoming list and rejects an empty one, because:
Invalid domain state should be difficult to construct.
The same Product may appear at most once in one Order. CreateOrderUseCase performs the primary check, rejecting duplicate ProductIds before any Product or Inventory work; Order construction defends the resulting invariant as well.
A new Order is always UNPAID, while persisted state may legitimately be any of the three states. So create(...) offers no way to choose a status, and reconstitute(...) lets persistence load existing state. Both run through the same validating constructor: reconstruction is not new business creation, but it cannot invent structurally invalid Orders either.
Time and the Clock
Order holds an Instant createdAt. CreateOrderUseCase receives a Clock through constructor injection and passes Instant.now(clock) to Order.create(...), so the use case owns the time source and the Order owns the resulting business state.
Order Lifecycle and Total
Order exposes cancel() and markPaid() instead of a public setStatus(...), so only UNPAID can become CANCELLED or PAID. Why not two booleans, paid and cancelled? Because paid = true together with cancelled = true would become representable, and our rules say that state is invalid. An explicit OrderStatus expresses exactly the legal states.
The total is derived: the sum of unitPriceCents × quantity over the items, with Math.addExact for the sum. There is no setTotalCents(...) and no independent authoritative total. Putting it together gives the model this module is heading for; lessons 4.04 to 4.07 then build each part step by step, so their intermediate versions are smaller than this one:
package com.tarkev.ordermanagement.order.domain;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
public final class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private final Instant createdAt;
private OrderStatus status;
private Order(OrderId id, CustomerId customerId, List<OrderItem> items,
OrderStatus status, Instant createdAt) {
this.id = Objects.requireNonNull(id, "id");
this.customerId = Objects.requireNonNull(customerId, "customerId");
this.items = validateItems(items);
this.status = Objects.requireNonNull(status, "status");
this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
}
public static Order create(OrderId id, CustomerId customerId,
List<OrderItem> items, Instant createdAt) {
return new Order(
id, customerId, items, OrderStatus.UNPAID, createdAt);
}
public static Order reconstitute(OrderId id, CustomerId customerId,
List<OrderItem> items, OrderStatus status, Instant createdAt) {
return new Order(id, customerId, items, status, createdAt);
}
public void cancel() {
if (status != OrderStatus.UNPAID) {
throw new OrderNotCancellable();
}
status = OrderStatus.CANCELLED;
}
public void markPaid() {
if (status != OrderStatus.UNPAID) {
throw new OrderNotPayable();
}
status = OrderStatus.PAID;
}
public long totalCents() {
long total = 0;
for (OrderItem item : items) {
total = Math.addExact(total, item.totalCents());
}
return total;
}
public OrderId id() { return id; }
public CustomerId customerId() { return customerId; }
public List<OrderItem> items() { return items; }
public OrderStatus status() { return status; }
public Instant createdAt() { return createdAt; }
private static List<OrderItem> validateItems(List<OrderItem> items) {
List<OrderItem> copy = List.copyOf(items);
if (copy.isEmpty()) {
throw new IllegalArgumentException(
"Order must contain at least one item.");
}
long products = copy.stream()
.map(OrderItem::productId).distinct().count();
if (products != copy.size()) {
throw new IllegalArgumentException(
"Order cannot contain the same Product twice.");
}
return copy;
}
}
List.copyOf copies the caller's list into an unmodifiable one, so items() can return it directly.
Domain vs Use Case
The amount flows from Product.priceCents through CreateOrderUseCase into OrderItem.unitPriceCents and Order.totalCents(), never from a client-supplied total, and Order never calls productRepository.findById(...): the use case loads the Products, captures their prices, consumes Inventory and persists the new Order, while Order works only with state it owns. Neither should absorb the other. A useful test:
Can this rule be decided using only state owned by this object?
"A PAID Order cannot be cancelled" depends only on the status Order owns, so order.cancel() protects it. "Requested quantity must not exceed current persisted Inventory" needs the request, the Inventory state and database concurrency protection, so CreateOrderUseCase and InventoryRepository coordinate it. Domain objects therefore do not know handlers, do not save themselves (an order.save() would be Active Record, which we have not chosen) and do not call external services: Order decides whether it may become PAID, but PayOrderUseCase calls the provider.
Encapsulation Without Setters
Reading state through order.status() is fine; the problem is unrestricted mutation that bypasses ownership rules. Before adding a setter, ask: Which business operation requires unrestricted replacement of this state? Often the answer is none, which is why Product has changePrice(...) and deactivate() and Order markPaid() and cancel(). inventory.setAvailableQuantity(exactQuantity) itself is meaningful, though: it is the actual administrator operation.
Typed IDs are settled, so a ProductId can never be passed where an OrderId is required, but not every primitive gets a value object: a quantity is an int, and money is long cents (lesson 4.06).
Construction and Persistence Come Second
With new Order() followed by setCustomerId(...), setStatus(...) and setItems(...), the object passes through partially invalid states; with Order.create(...), required state exists from the start. We do not weaken this because "JPA might want setters later": persistence should support the domain, not dictate it. Whether persistence uses @ManyToOne or raw UUID columns is decided later, and we create no BaseEntity with id, createdAt and updatedAt for everything to inherit: shared technical columns are not shared behaviour. Nor does Order need an OrderCreatedEvent or an AggregateRoot base class to be a consistency boundary.
If a request contains quantity = -5, the handler may reject it, yet OrderItem still protects quantity > 0, because that is a domain invariant; lesson 4.03 decides which boundary owns which rule.
Domain Failures
The lifecycle failures are OrderNotCancellable and OrderNotPayable; Inventory may throw InsufficientInventory. Each is a small type next to its behaviour. No large hierarchy is needed, only types that carry meaning and let the application boundary classify expected business rejections:
package com.tarkev.ordermanagement.order.domain;
public final class OrderNotCancellable extends RuntimeException {
}
package com.tarkev.ordermanagement.order.domain;
public final class OrderNotPayable extends RuntimeException {
}
package com.tarkev.ordermanagement.inventory.domain;
public final class InsufficientInventory extends RuntimeException {
}
A Framework-Light Domain That Evolves
Calling cancel() on an unpaid Order is testable in plain JUnit, without an ApplicationContext, an HTTP request or a database. This model is not final either: start with a model that accurately reflects confirmed behaviour and evolve it deliberately when new evidence appears, which rules out optimizing for shipping, returns or discounts our backend does not have.
The Model So Far
Product Inventory
├── ProductId (UUID) ├── ProductId
├── name └── availableQuantity (int)
├── priceCents (long)
└── active
Order OrderItem
├── OrderId (UUID) ├── ProductId
├── CustomerId (String) ├── quantity (int)
├── OrderStatus └── unitPriceCents (long)
├── createdAt (Instant)
└── OrderItems
There is no InventoryId, OrderItemId or persisted total. This is no architecture change: we translate the model reviewed in RFC-001 and the ADRs into Java. JPA mappings, the schema, repositories, REST DTOs and transactions arrive in their own lessons.
Practice Exercise 1: Test the Inventory Invariant
Solutions: lesson 4.09.
Inventory is in your project, but no test describes it yet. Write a plain JUnit 5 test class under src/test/java/com/tarkev/ordermanagement/inventory/domain/ that proves:
Inventory.create(productId, -1)is rejected, andInventory.create(productId, 0)is accepted.decrease(3)on an Inventory of 2 throwsInsufficientInventory, and the quantity is still 2 afterwards.increase(0)anddecrease(0)are rejected, because an amount must be positive.setAvailableQuantity(-5)is rejected and leaves the previous quantity in place.
Run ./gradlew test. For each rejection, assert the state after the failure as well as the exception: a rule that throws after changing the field still leaves a broken object behind.
Practice Exercise 2: Find the Invalid States
Go back to the data-holder Order at the start of this lesson, with its public getStatus() and setStatus(String), and imagine it also had setItems(List<OrderItem>) and setCustomerId(CustomerId). List four invalid Orders a caller could produce with it. For each one, name the part of this lesson's Order (a factory method, the constructor, a check, a missing method) that makes it impossible.
Practice Exercise 3: Whose Rule Is It?
For each rule, say whether one domain object can decide it from the state it owns (name the object and method) or whether a use case must coordinate it (say what else it needs):
- A Product's name cannot be blank.
- A Customer may cancel only their own Order.
- A
CANCELLEDOrder cannot be paid. - Two Customers must not both get the last unit of a Product.
- A new Order may contain only active Products.
- An Order's total is the sum of its lines.
Knowledge Check
Question 1
order.setStatus(OrderStatus.CANCELLED) and order.cancel() can both end with a CANCELLED Order. What does the second give us that the first cannot?
Question 2
The HTTP API will reject a negative price before it reaches the domain. Why does Product still reject -500?
Question 3
Why does Order have both create(...) and reconstitute(...) instead of one public constructor that takes a status?
Question 4
What could go wrong if Order stored the caller's list directly (this.items = items) instead of List.copyOf(items)?
Question 5
inventory.decrease(1) refuses to go below zero. Why does it still not stop two simultaneous Orders from selling the last unit twice?
Knowledge Check Answers
Answer 1
cancel() puts the rule "only an UNPAID Order can be cancelled" inside the object that owns the status, so every caller gets it. A setter only writes the field and leaves every caller to remember the rule.
Answer 2
The HTTP request is only one way to create or change a Product. Tests, imports, other workflows and persistence also do it, and the domain invariant holds whichever way the state arrives.
Answer 3
A new Order is always UNPAID, so create(...) gives the caller no status to choose. Persistence must restore Orders that are already PAID or CANCELLED, and reconstitute(...) makes that a separate, visible intent. Both run the same validating constructor.
Answer 4
The caller could later clear() or change its list and so change the Order from outside, even into an Order with no items. The copy gives the Order its own unmodifiable snapshot.
Answer 5
Each transaction loads its own Inventory object from the same row with quantity 1, so both in-memory checks pass. Only an atomic conditional update in the database (ADR-005, lesson 7.08) decides between them.
Engineering Principle
A domain model should represent business meaning, valid state and meaningful behaviour, not merely mirror database columns.
Put a rule on the object that owns the state needed to enforce it, let use cases coordinate rules spanning several concepts or infrastructure, and make invalid business state difficult to construct.
Summary
- Business language, not tables or annotations, drives the domain model.
- Product owns its name, non-negative
priceCentsand active state behind a UUIDProductId; active does not mean orderable, and v1 has no reactivation. - Inventory is identified by
ProductId; its quantity is never negative, and concurrent consumption relies on ADR-005's atomic update, notdecrease(...). - Order has a UUID
OrderId, a StringCustomerId, exactly three states,createdAtfrom an injectedClock, andcancel()/markPaid()instead of a setter. - OrderItem holds
ProductId, a positive quantity and the historical price, with no ID of its own; Order owns an unmodifiable, non-empty list of them. - Money is
longcents, and totals are derived with exact arithmetic, never supplied by the client. create(...)differs fromreconstitute(...); invalid state is hard to construct.- Single-object rules live on the object; use cases coordinate cross-concept and infrastructure rules.
In the next lesson, Entities, Value Objects, and DTOs, we classify these types: which are entities, which are value objects, and why API request and response models are a different kind of object altogether.
Think first
Coming soonCheckpoint questions that ask you to commit to an answer before the lesson shows it.