Õppetund 12/159
Kirjalik õppetund · 21 min

Identifying Domain Entities

Which concepts are entities, which are values, what each owns, and why Order is the aggregate root.

Eelvaade

Vaatad tasuta eelvaate õppetundi.

In the previous lesson, we started moving from requirements into technical design, beginning with business behaviour and the responsibilities it implies. Now we need to identify the important domain concepts inside our Order Management Backend. Our requirements keep referring to Customer, Product, Inventory, Order, OrderItem and Payment. But finding nouns in a requirement and creating one Java class for each noun is not domain modelling.

We need to understand which concepts have identity and their own lifecycle, which belong inside another concept, which values are current state and which are historical facts, and which rules each concept protects. In this lesson, we stay at the domain-design level. We will not design JPA mappings or final database tables yet. The goal is a clear model of the business problem.

What Is a Domain?

The domain is the problem space the software exists to solve. Our business area is commerce, but we are not building a whole commerce platform. Our current problem space is the Product catalog, inventory, ordering, Order history, cancellation and payment. Shipping, warehouses, discounts, coupons, returns, refunds, recommendations, supplier management and accounting are outside our current scope.

A concept may exist in real commerce platforms without belonging in our application.

The domain model should represent the problem we are solving now.

Domain Models and Entities

A domain model represents business identity, state, relationships, rules and behaviour. Consider Order. It is not merely an id, a customerId, a status and a createdAt. It also has meaning: Can this Order be paid or cancelled? Which customer owns it? Which OrderItems belong to it? Which state transitions are valid? What historical pricing does it preserve? A useful domain model captures these meanings instead of treating everything as mutable data.

An entity is a domain concept whose identity matters. Suppose Product A and Product B are both named "Keyboard" and both cost 5000 cents. Even though all visible properties are the same, A and B are different Products, because they have different identities. An entity's properties may change while its identity stays stable: when Product A's price changes from 5000 to 5500 cents, it is still the same Product.

A Domain Entity Is Not a JPA @Entity

This distinction is important. In domain modelling, an entity is a business concept whose identity and lifecycle matter. In JPA, @Entity is a persistence mechanism. When we say "Order is a domain entity", we are not yet deciding that the same Java class must carry @Entity; the persistence model comes later. Likewise, a domain entity is not a database table. They may map closely later, but they are different design concepts.

Value-Like Concepts

Not every concept needs independent identity. Two amounts of 2000 cents represent the same monetary value in the same context; we do not need "Money #123" and "Money #456" to tell them apart. Quantity, OrderStatus and price are value-like in the same way.

That does not mean every value needs a custom Java class. A domain concept and an implementation abstraction are separate decisions. Our current money representation is simply long priceCents, and we do not create a Money value object merely for ceremony.

Customer Identity and CustomerId

Let's analyze each concept, starting with the customer. A customer creates Orders and views, cancels and pays for their own Orders. Identity matters because customer A and customer B are different actors, and the critical ownership rule is that customer A cannot access customer B's Orders. So the ordering application needs a stable customer identity.

Do we need a Customer entity? No. Our engineering context says authentication comes from an existing identity capability, and our backend does not own registration, password storage, password reset, email verification, MFA or the account lifecycle. The ordering domain only needs a CustomerId, so it can say "this Order belongs to customer X". The identity system turns an authenticated identity into a CustomerId for our backend: the external system owns authentication, and our backend owns Order ownership.

For this project, CustomerId is a small domain/application concept: a stable identifier supplied by the authentication context. We deliberately do not assume that it is a UUID. Conceptually:

java
public record CustomerId(String value) {
}

The exact implementation comes later; the rule is that a CustomerId is stable and non-blank. There is no local customer table.

Product

Product is clearly a domain entity. A Product has stable identity, current catalog information (its name), a current price, an active/inactive state and a lifecycle independent from Orders. Product P1 with priceCents = 2000 and active = true is still the same Product after its price becomes 2500, and still the same Product after it is deactivated.

Product is a domain entity.

Our project uses UUID-based Product identity: a ProductId wraps a java.util.UUID generated by the application, and the HTTP API exposes it in textual form.

For the current v1 scope, Product owns its stable identity, current name, current priceCents and active/inactive state. It protects Product-local rules (the name must be valid, priceCents cannot be negative) and meaningful transitions such as deactivation.

There is an important distinction, though: an active Product is not automatically orderable. A Product with active = true whose inventory has availableQuantity = 0 is active from the catalog perspective, but a customer cannot currently order it. Product state and inventory availability are different facts.

Inventory

We could have modelled inventory as a fourth Product field next to name, price and active, but our requirements suggest a separate concern. Product changes through a new name, a new price or deactivation. Inventory changes for different reasons: an administrator sets stock, Order creation consumes stock, and Order cancellation restores it. These mutation patterns and responsibilities differ.

Inventory is a separate stateful domain concept.

Inventory does not need its own InventoryId. For v1, inventory identity is the ProductId: "the inventory for Product P1" is sufficient, so one Product has at most one inventory state in the current model. Later, persistence may use product_id as the inventory primary key. This avoids inventing an identifier with no business value.

Inventory owns the ProductId and availableQuantity, with the basic invariant availableQuantity >= 0. It may expose meaningful behaviour such as setting, decreasing and increasing the available quantity. For Order creation, requestedQuantity <= availableQuantity must hold for successful consumption, and cancellation restores consumed inventory. The Product entity does not own these rules: Inventory owns inventory state. However, concurrency-safe Order consumption will need persistence support beyond an in-memory domain method, a distinction that becomes important later.

Product Availability vs Inventory Availability

We should avoid ambiguous fields such as boolean available. What would available mean? That the Product is active? That inventory is above zero? That there is enough inventory for this specific requested quantity? These are different concepts.

Instead, the model stays explicit: Product state is active, inventory state is availableQuantity, and orderability depends on the relevant combination. For Product browsing, a Product is shown when it is active and availableQuantity > 0. For Order creation, it must be active and availableQuantity >= requestedQuantity. This is clearer than hiding several meanings behind one boolean.

Order

Order is the central domain entity in our system. An Order has stable identity, customer ownership, lifecycle state, a creation time, OrderItems, historical pricing, a total, a payment-related state transition and cancellation rules.

Order is a domain entity.

Orders use UUID-based identity: an OrderId wraps a java.util.UUID. Once created, Order O1 remains the same Order while its state changes from UNPAID to PAID.

An Order is more than data. A weak model would be:

java
class Order {
    UUID id;
    String customerId;
    String status;
}

with arbitrary mutation such as order.setStatus("CANCELLED"). But our requirements say PAID Orders cannot be cancelled, CANCELLED Orders cannot be paid, and only UNPAID Orders can transition to PAID or CANCELLED. So the domain should express meaningful operations, such as order.cancel() and order.markPaid(), rather than a generic status setter.

An entity should protect meaningful business state where appropriate.

The Order Lifecycle

Our v1 lifecycle is intentionally small:

Diagrammi joonistamine…

PAID → CANCELLED and CANCELLED → PAID are invalid: PAID and CANCELLED are terminal states in v1. We do not add PROCESSING, PACKED, SHIPPED, DELIVERED, RETURNED or REFUNDED, because none of them belongs to our current requirements.

Why UNPAID rather than PENDING? A vague state such as PENDING could mean pending payment, approval, fulfilment or processing. Our meaning is specific: the Order exists, but successful payment has not been applied. UNPAID communicates that domain state more clearly.

OrderItem and Purchase-Time Price

An Order may contain multiple Products, but it cannot simply keep Product references. Each purchased line needs a ProductId, a quantity and a purchase-time unitPriceCents. That concept is the OrderItem.

Why does the purchase-time price belong to OrderItem? Suppose Product P1 costs 2000 cents and a customer creates Order O1 with P1 × 2 @ 2000 cents. Later, P1 costs 2500 cents. Historical O1 must still show P1 × 2 @ 2000 cents, while a new Order uses 2500. Product.priceCents is the current catalog price; OrderItem.unitPriceCents is the price at Order creation. These are different facts, and storing both is not meaningless duplication. It preserves business history.

Should we snapshot the Product name too? Our current requirement explicitly requires historical pricing, but not historical Product-name snapshots. So we do not invent a purchaseTimeProductName or copy the full Product into the Order. Historical facts are limited to what the current requirements justify; if a future requirement needs the original name or description, the design can evolve.

Does OrderItem Need Its Own Identity?

Users never fetch, cancel, pay for or modify one OrderItem independently: it exists as part of an Order, with the Order's lifecycle. So v1 needs no domain identity such as OrderItemId.

Even persistence does not require one. Because the same Product may appear at most once in one Order, the pair (order_id, product_id) identifies an item. OrderItem identity within persistence is OrderId plus ProductId, which matches our duplicate-Product rule naturally.

Order Is the Aggregate Root

Ownership matters. Suppose external code could change an item's quantity from 2 to 5 without the Order controlling its items. The Order total and historical meaning could become inconsistent.

Order owns its OrderItems and protects Order-level consistency.

This makes Order a natural aggregate root. We do not need a full Domain-Driven Design course here, but the idea of an aggregate is useful: it defines a consistency boundary around related domain objects. Our natural aggregate is the Order with its OrderItems, with Order as the root. Application behaviour works through the Order rather than treating OrderItems as independently mutable objects.

Product is not part of the Order aggregate. OrderItem references a Product, but Order does not own it: a Product exists before the Order, appears in many Orders, changes independently and has its own lifecycle. Inventory is not part of the aggregate either. It exists independently and is changed by Order creation, Order cancellation and administrators setting quantities. Order creation coordinates Product, Inventory and Order, but they remain separate domain concepts.

Clear ownership prevents modelling errors: inventory inside Order would imply that one Order owns the inventory lifecycle, and a mutable Product copy inside Order would confuse current catalog state with historical transaction state.

The Order Total

The Order total needs no identity of its own. It is derived from the items, as the sum of unitPriceCents × quantity (with overflow-checked arithmetic, implemented in 7.07). The invariant is that the Order total stays consistent with its OrderItems, and deriving it rather than persisting a separate authoritative total avoids two sources of truth.

Quantity and Money

Quantity is value-like, with the rule quantity >= 1, and inventory consumption requires the requested quantity to be at most the available quantity. The simplest Java representation is int quantity. We could introduce a Quantity value object, but we will not unless it provides enough practical value to justify the abstraction.

Money requires exact arithmetic. Our canonical representation is long cents (2000 means 20.00 currency units), not double or float; we do not use BigDecimal either, because our pricing model is whole minor units, and with no multi-currency model in scope we avoid a generic Money(amount, currency) framework. Module 4 implements this.

Product owns its current long priceCents, with the rule priceCents >= 0; zero is valid and may represent a free Product. OrderItem owns its historical long unitPriceCents, also >= 0, and once the Order is created this value does not change merely because the Product's price changes later.

Payment

Payment's persistent domain model is intentionally not finalized here. Our requirements say a customer can pay an eligible Order, a confirmed successful payment makes the Order PAID, a failed payment does not, and a successful payment outcome must not be applied twice. Actual payment execution belongs to an external provider. Our application cares about which Order is paid, whether the payment was confirmed and already applied, and which provider information safe integration needs.

Do we need a Payment entity? Possibly later, but not before the integration requirements justify it. A future design might need a Payment or PaymentAttempt if provider behaviour introduces concepts such as "attempt 1 timed out, attempt 2 succeeded". We have not designed that workflow yet.

Payment is a separate integration concern, but its exact persistent model is deferred until the payment module.

We also rule out the simplest representation, boolean paid, as the domain model. Our Order already has lifecycle state, so a second boolean could create contradictory state such as status = CANCELLED with paid = true. The Order status already expresses the lifecycle we need, and provider-specific payment state can be modelled when the integration requirements are known.

Domain Relationships

Putting the concepts together, the domain-level view looks like this (it is not yet a database schema or package diagram):

Diagrammi joonistamine…

A CustomerId owns many Orders, and payment stays outside the Order aggregate behind an integration boundary. Domain relationships are not automatically JPA relationships either: "Order contains OrderItems" does not mean we immediately choose @OneToMany, and "OrderItem references Product" does not mean @ManyToOne. We first ask what business relationship exists, who owns the lifecycle and what consistency is required; persistence design later weighs loading behaviour, coupling and query patterns. Persistence should support the domain model rather than define it prematurely.

Current Domain Invariants

Let's collect the important rules:

  • Product: stable identity; a valid name; priceCents not negative; an active/inactive state; inactive Products cannot be used for new Orders.
  • Inventory: identified by ProductId; availableQuantity not negative; successful Order creation cannot consume more than is available.
  • Order: stable identity; belongs to one CustomerId; contains at least one OrderItem; has a valid lifecycle state; new Orders begin as UNPAID; PAID Orders cannot be cancelled in v1; CANCELLED Orders cannot become PAID.
  • OrderItem: belongs to an Order; references a ProductId; quantity is positive; unitPriceCents is non-negative; the purchase-time price is preserved. At request level, the same Product appears at most once in one Order.
  • Order total: equals the sum of the item totals.
  • Customer ownership: customer-scoped operations affect only that customer's Orders.
  • Payment: only an UNPAID Order is payable; a failed payment does not make it PAID; a confirmed payment is not applied twice.

Domain Behaviour vs Workflow Coordination

Not every rule belongs entirely inside one entity. "Requested quantity ≤ available inventory" needs both the Order request and current inventory state, and "cancellation restores inventory" crosses Order and Inventory. A domain entity protects rules about its own state: order.cancel() ensures that a PAID Order cannot become CANCELLED. An application use case coordinates several concepts and their persistence; an Order that held an OrderRepository and an InventoryRepository and saved itself inside cancel() would own infrastructure and orchestration it has no business knowing about.

Order owns Order-state rules. The use case owns cross-concept coordination.

The next lesson assigns these coordination responsibilities in detail.

Make Invalid State Hard to Represent

Make invalid state difficult to create.

Instead of allowing new Order(emptyItems) and hoping another layer checks it, Order construction rejects empty items. Instead of accepting inventory.setAvailableQuantity(-5), Inventory rejects the invalid quantity. Instead of setters such as order.setStatus(...) or product.setActive(...) that let any caller manipulate state without expressing intent, the model offers meaningful operations: order.cancel(), order.markPaid(), product.deactivate(), and for Inventory setAvailableQuantity(...) (the administrator's real operation), decrease(...) and increase(...). Module 4 builds these rules in Java. We also avoid a BaseCommerceEntity superclass: shared technical fields such as an ID do not make a useful business "is-a" relationship.

Orders Are Compositions, Not Shopping Carts

Order and OrderItem are a composition, so we do not create an OrderItemHandler or OrderItemUseCase just because an order_items table will exist later. Our requirement also says a customer places an Order; it does not include a cart, adding or removing items later, or changing quantities after creation. So OrderItems are not modelled like editable cart lines. If a future requirement introduces a cart, it becomes a separate domain concept.

Deactivation and Historical Meaning

Our Product lifecycle uses deactivation rather than physical deletion: future Orders cannot use an inactive Product, and historical Orders remain intact. Once created, an Order represents a historical business event, and later Product changes should not rewrite it:

Order is a historical business record, not a live projection of the current Product catalog.

Consumption, Not Reservation

Earlier material sometimes said "reserve inventory". For v1, that is not our model. Order creation consumes available inventory, and eligible cancellation of an UNPAID Order restores it. We do not create an InventoryReservation or a reservation lifecycle; a reservation subsystem would introduce expiry, timeouts, background cleanup and reservation ownership, none of which is currently required.

Similarly, a failed payment does not cancel an Order: the Order remains UNPAID. We have not established "payment fails, so the Order becomes CANCELLED", so we do not invent that transition. If the business later wants automatic cancellation, it becomes a separate requirement.

Our Current Domain Model

We can now summarize the domain model:

ConceptKindIdentityOwns
ProductDomain entityProductId (UUID)Current name, current priceCents, active/inactive state
InventoryStateful domain conceptProductIdCurrent availableQuantity and its invariant
Customer identityExternally ownedCustomerIdUsed for Order ownership and customer authorization; no customer account subsystem
OrderDomain entity, aggregate rootOrderId (UUID)CustomerId, OrderStatus, createdAt, OrderItems, historical meaning, Order-level invariants
OrderItemOwned by OrderNone in the domain; OrderId + ProductId in persistenceProductId, quantity, unitPriceCents
PaymentExternal integration concernDeferredPaymentService talks to the provider and returns the outcome PayOrderUseCase needs; the persistent model is deferred

What We Deliberately Did Not Model

We did not introduce a Customer entity, Cart, Shipment, Warehouse, Discount, Coupon, Refund, Return, Supplier, Category, Recommendation, Invoice, InventoryReservation, event bus or PaymentAttempt, because none is currently required. That is good domain modelling.

The best model is not the one with the most concepts. It is the smallest model that accurately represents the current business problem.

Some questions intentionally remain open: how the Order aggregate maps to JPA (relationships or stored identifiers), what the repository contracts look like, how Order creation consumes inventory safely under concurrency, how cancellation locks Order state, and which payment records and idempotency rules are needed. They are not forgotten; they belong to later persistence, transaction, concurrency, security and payment design.

Practice Exercise 1: Model a Requirement Paragraph

Solutions: lesson 2.08.

This is a modelling experiment: the course does not add categories. Suppose the business sends this paragraph:

text
Administrators group Products into categories such as "Cables" or "Desks".
An administrator can rename a category, and the new name must appear on every
Product in it. Each Product belongs to exactly one category. Customers can
filter browsing by category. Each Product is shown as "available" in the
catalog.

In docs/practice/2.02-categories.md, write a table with one row per concept you find (Category, the category name, the Product–category link, "available"): its kind (entity, value-like, owned part, or not a new concept), its identity, what it owns and why. Then answer: does OrderItem need to keep the category it was bought in, and what does "available" have to mean here?

Practice Exercise 2: Review a Domain Draft

Find at least five problems in this draft model and name the rule from this lesson each one breaks:

text
Order     { id, customerId, status, paid: boolean, totalCents, items }
OrderItem { id: OrderItemId, product: Product, quantity }
Product   { id, name, priceCents, active, availableQuantity }
Customer  { id, email, passwordHash }

Knowledge Check

Question 1

Two Products are both named "Keyboard" and cost 5000 cents. Why are they two Products, while two amounts of 2000 cents are the same value?

Question 2

Why does Inventory need no InventoryId?

Question 3

What would happen to Order history if it computed totals from the current Product.priceCents?

Question 4

Why does a failed payment leave the Order UNPAID instead of moving it to CANCELLED?

Question 5

Every OrderItem references a Product. Why is Product still not part of the Order aggregate?

Knowledge Check Answers

Answer 1

Products are entities: identity, not their current properties, tells them apart, and either can change its price and still be the same Product. Money is value-like: 2000 cents is 2000 cents, and nothing distinguishes one amount from another.

Answer 2

In v1 a Product has at most one inventory state, so "the inventory of Product P1" already identifies it. A separate ID would carry no business meaning.

Answer 3

Every price change would silently rewrite past Orders: an Order bought at 2000 cents per unit would show 2500 after a price increase, although the Order itself never changed.

Answer 4

No requirement says a failed payment cancels the Order, and the customer may simply try again. Inventing that transition would add behaviour, and an inventory restoration, nobody asked for.

Answer 5

A Product exists before any Order, appears in many Orders and changes on its own lifecycle. The Order owns only what it must keep consistent: its items and their purchase-time prices.

Engineering Principle

A Domain Entity exists because its identity, lifecycle, and behaviour matter to the business—not because we need another Java class or database table.

Ownership must be explicit: Order owns its OrderItems, but not Product, Inventory, customer identity or the external payment provider. And we model only the concepts the current problem requires.

Summary

  • A domain model represents business identity, state, relationships, rules and behaviour; a domain entity is not a JPA @Entity or a table.
  • Product is an entity with a UUID ProductId, a current price and an active/inactive state; Inventory is a separate stateful concept identified by ProductId, because its lifecycle and mutation patterns differ.
  • Customer identity is external: our backend uses a CustomerId for ownership, with no local customer entity.
  • Order is the central entity and the aggregate root for its OrderItems; OrderItem has no domain identity (persistence can use OrderId + ProductId) and stores the purchase-time unitPriceCents.
  • The Order total is derived from its items, and money is long cents.
  • The lifecycle is UNPAID, PAID, CANCELLED, with PAID and CANCELLED terminal; a failed payment leaves the Order UNPAID, and inventory is consumed and restored, not reserved.
  • Entities protect their own state; cross-concept workflows belong in use cases, and entities never call repositories, HTTP, Spring Security or provider APIs.
  • Payment stays behind PaymentService with its persistent model deferred, and concepts such as Cart, Warehouse and Refund are not modelled without requirements.

In the next lesson, Defining System Responsibilities, we decide which responsibilities belong to Product, Inventory, Order, Payment, the security / customer context, use cases, repositories and HTTP handlers, so that business logic does not collapse into controllers or giant internal services.

Mõtle enne

Tulekul

Kontrollküsimused, mille puhul valid vastuse enne, kui õppetund selle näitab.