Domain Modeling
Modeling Orders and Order Items
আপনি একটি free preview lesson দেখছেন।
আমাদের Order Management Backend-এর সবচেয়ে গুরুত্বপূর্ণ domain concept হলো:
Order
Product এবং Inventory গুরুত্বপূর্ণ, কিন্তু customer-এর business action শেষ পর্যন্ত Order-এর মাধ্যমে materialize হয়।
একটি successful Order creation-এর সময় backend-কে capture করতে হয়:
who placed the Order
which Products were ordered
how many units were ordered
what each Product cost at that moment
what the Order total is
what lifecycle state the Order starts in
আমাদের accepted design অনুযায়ী একটি Order:
belongs to one CustomerId
contains one or more OrderItems
starts as UNPAID
preserves purchase-time prices
derives its total from OrderItems
does not allow duplicate Product lines
এই lesson-এর goal:
Order এবং OrderItem-কে এমনভাবে model করা যাতে successful Order creation-এর পরে historical state internally consistent থাকে এবং arbitrary mutation দিয়ে rules bypass করা না যায়।
Start From the Business Facts
একটি customer request করতে পারে:
{
"items": [
{
"productId": "P-100",
"quantity": 2
},
{
"productId": "P-200",
"quantity": 1
}
]
}
কিন্তু successful Order-এর মধ্যে শুধু এই request data থাকবে না।
Backend current Product state থেকে authoritative price capture করবে।
Suppose:
P-100 current price = 20.00
P-200 current price = 50.00
তাহলে Order state হবে conceptually:
Order
├── CustomerId = C-10
├── Status = UNPAID
├── OrderItem
│ ├── ProductId = P-100
│ ├── Quantity = 2
│ └── UnitPrice = 20.00
└── OrderItem
├── ProductId = P-200
├── Quantity = 1
└── UnitPrice = 50.00
Total:
2 × 20.00
+
1 × 50.00
=
90.00
এই state successful Order creation-এর historical record।
Order and OrderItem Have Different Responsibilities
Order owns:
customer ownership
collection of items
order lifecycle
total consistency
OrderItem owns:
Product reference
ordered quantity
purchase-time unit price
Neither should own:
current Product price lookup
Inventory persistence
HTTP request parsing
authentication
Payment Provider communication
Those belong elsewhere।
Order Is an Entity
Order has stable identity।
For example:
OrderId = O-1001
Initially:
status = UNPAID
Later:
status = PAID
The state changes, but we are still referring to:
Order O-1001
Therefore Order is clearly a domain Entity।
OrderItem Is Owned by Order
Current requirements do not give OrderItem an independent lifecycle।
We do not have operations such as:
create standalone OrderItem
load OrderItem independently
change one OrderItem after Order creation
Instead:
Order
├── OrderItem
└── OrderItem
Order owns the item collection।
For v1, OrderItem can be modeled as an immutable owned value-like object।
OrderItem State
A useful first model:
public record OrderItem(
ProductId productId,
int quantity,
BigDecimal unitPrice
) {
}
This shape is compact, but it currently accepts invalid values।
For example:
new OrderItem(
productId,
-5,
new BigDecimal("-10.00")
);
That should never represent a valid OrderItem।
OrderItem Invariants
From our requirements:
Product reference must exist conceptually
quantity must be positive
unit price cannot be negative
The Product existence check itself requires repository state and belongs in CreateOrderUseCase.
But once we construct an OrderItem, it should receive a valid ProductId and valid values।
A Stronger OrderItem
Conceptually:
public record OrderItem(
ProductId productId,
int quantity,
BigDecimal unitPrice
) {
public OrderItem {
if (productId == null) {
throw new IllegalArgumentException(
"Product ID is required"
);
}
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
if (unitPrice == null) {
throw new IllegalArgumentException(
"Unit price is required"
);
}
if (unitPrice.signum() < 0) {
throw new IllegalArgumentException(
"Unit price cannot be negative"
);
}
}
}
Now successful construction gives us stronger guarantees।
Why OrderItem Should Be Immutable
After successful Order creation, current requirements do not support:
changing quantity
changing Product
changing purchase-time price
That makes immutability a natural fit।
A mutable:
orderItem.setUnitPrice(...)
would be especially dangerous because it could rewrite historical pricing।
Purchase-Time Price
This is one of the most important decisions in our model।
Suppose Product price is:
20.00
when Order is successfully created।
OrderItem records:
unitPrice = 20.00
Later Product changes to:
25.00
The existing OrderItem must remain:
20.00
because it represents what the customer ordered at creation time।
Product Price vs OrderItem Price
These fields represent different facts:
Product.price
→ current catalog price
OrderItem.unitPrice
→ purchase-time historical price
This is not accidental duplication।
It is deliberate historical modeling।
Client Does Not Supply Authoritative Unit Price
Incoming request should not control:
unitPrice
The correct flow is:
CreateOrderRequest
↓
ProductId + quantity
↓
CreateOrderUseCase
↓
load Product
↓
read current server-side price
↓
construct OrderItem
This protects pricing authority।
OrderItem Total
Each OrderItem can calculate its own subtotal।
Conceptually:
public BigDecimal total() {
return unitPrice.multiply(
BigDecimal.valueOf(quantity)
);
}
Now one OrderItem knows:
quantity × purchase-time unit price
This is local behaviour because both values belong to the OrderItem।
A Complete OrderItem
Conceptually:
public record OrderItem(
ProductId productId,
int quantity,
BigDecimal unitPrice
) {
public OrderItem {
if (productId == null) {
throw new IllegalArgumentException(
"Product ID is required"
);
}
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
if (unitPrice == null) {
throw new IllegalArgumentException(
"Unit price is required"
);
}
if (unitPrice.signum() < 0) {
throw new IllegalArgumentException(
"Unit price cannot be negative"
);
}
}
public BigDecimal total() {
return unitPrice.multiply(
BigDecimal.valueOf(quantity)
);
}
}
Simple, immutable, এবং meaningful।
Now Order
A first conceptual Order model:
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
}
Eventually we may also need:
createdAt
for history ordering and display।
But let's first establish core business state।
Order Must Have an Owner
Every customer-created Order belongs to exactly one authenticated customer।
Therefore:
private final CustomerId customerId;
is required state।
A valid Order should not exist with:
customerId = null
for our v1 customer order workflow।
Order Must Have Items
Requirement:
Order must contain at least one item.
So:
new Order(
orderId,
customerId,
List.of()
);
must not create a valid Order।
Protect the Item Collection
Bad:
this.items = items;
Why?
Caller could pass:
List<OrderItem> items =
new ArrayList<>();
construct Order, then later do:
items.clear();
Now Order becomes empty from outside without invoking any Order behaviour।
Defensive Copy
Better:
this.items = List.copyOf(items);
This protects Order from external mutations to the original collection।
It also makes the stored list unmodifiable through the returned reference।
Validate Null Collections
Conceptually:
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException(
"Order must contain at least one item"
);
}
Then:
this.items = List.copyOf(items);
A valid Order always has at least one OrderItem।
Validate Null Items Too
Even if list is non-empty:
List.of(
validItem,
null
)
would be invalid domain state।
If using List.copyOf(...), Java itself rejects null elements, but relying on subtle library behaviour for domain meaning is not always ideal।
We can validate explicitly if clearer।
For example:
if (items.stream().anyMatch(
Objects::isNull
)) {
throw new IllegalArgumentException(
"Order items cannot contain null"
);
}
Duplicate Product Lines
Our accepted rule:
The same Product cannot appear multiple times in one Order.
So this request is invalid:
{
"items": [
{
"productId": "P-100",
"quantity": 1
},
{
"productId": "P-100",
"quantity": 2
}
]
}
Client should combine them into:
{
"items": [
{
"productId": "P-100",
"quantity": 3
}
]
}
Where Should Duplicate Detection Happen?
Primary request/workflow rejection belongs to:
CreateOrderUseCase
because the UseCase processes requested Product IDs before persistence and inventory mutation।
But Order itself can also protect the invariant:
one ProductId appears at most once
Why?
Because once an Order exists, we want it to represent valid business state regardless of caller।
Duplicate Protection in Order
Conceptually:
private static void requireUniqueProducts(
List<OrderItem> items
) {
long uniqueProductCount =
items.stream()
.map(OrderItem::productId)
.distinct()
.count();
if (uniqueProductCount != items.size()) {
throw new IllegalArgumentException(
"Order cannot contain duplicate products"
);
}
}
This prevents invalid Order construction।
Why Validate Duplicates Before Inventory Mutation Too?
Suppose request contains:
P-100 × 1
P-100 × 2
If we process Inventory line by line before detecting duplicates:
decrease 1
decrease 2
we create unnecessary complexity।
Better:
validate command structure first
↓
load required state
↓
perform business operation
So CreateOrderUseCase should reject duplicates early।
Order constructor remains the final invariant guard।
New Order Starts UNPAID
Our v1 lifecycle:
UNPAID
├── PAID
└── CANCELLED
A newly created Order has not been paid yet।
Therefore new Order starts:
UNPAID
The caller should not choose initial status।
Bad Constructor
Avoid:
new Order(
id,
customerId,
items,
requestedStatus
);
for normal new Order creation।
A caller could create:
PAID
without going through payment workflow।
Better Creation Model
Conceptually:
public Order(
OrderId id,
CustomerId customerId,
List<OrderItem> items
) {
this.id = Objects.requireNonNull(
id,
"Order ID is required"
);
this.customerId =
Objects.requireNonNull(
customerId,
"Customer ID is required"
);
requireItems(items);
requireUniqueProducts(items);
this.items = List.copyOf(items);
this.status = OrderStatus.UNPAID;
}
Now a newly constructed Order starts in the only valid initial state।
OrderStatus
For v1:
public enum OrderStatus {
UNPAID,
PAID,
CANCELLED
}
No:
SHIPPED
REFUNDED
PROCESSING
DELIVERED
because those states are outside current requirements।
Order Total Is Derived
Our design review established:
Order total is derived from OrderItems.
Therefore avoid:
private BigDecimal total;
plus:
setTotal(...)
as an independently mutable source of truth।
That could create inconsistency:
items total = 90
stored total = 75
Which one is correct?
We avoid the question by having one source of truth।
Derived Total
Conceptually:
public BigDecimal total() {
return items.stream()
.map(OrderItem::total)
.reduce(
BigDecimal.ZERO,
BigDecimal::add
);
}
Now total is always based on the recorded OrderItems।
Why This Is Safe for Historical Pricing
Because each OrderItem keeps immutable:
unitPrice
the derived total remains historically stable।
Product price changes later do not affect:
order.total()
No setTotal()
A public:
order.setTotal(...)
should not exist।
Client does not own total।
Handler does not own total।
Repository should not arbitrarily override total।
Order derives it from its own items।
Should We Persist the Total Anyway?
Maybe later for:
query performance
reporting
audit convenience
But if persisted, it would be derived data that must remain consistent with OrderItems।
Our accepted v1 design treats OrderItems as the authoritative source।
We will make the persistence decision later rather than introducing two mutable truths now।
Order Items Should Not Be Mutable From Outside
Avoid:
public List<OrderItem> items() {
return items;
}
if items is mutable internally।
Better if stored with List.copyOf(...) and returned safely:
public List<OrderItem> items() {
return items;
}
Since the list itself is unmodifiable and OrderItem is immutable, this is safe enough।
Alternatively:
return List.copyOf(items);
but repeated copying may be unnecessary if the stored collection is already immutable।
No addItem() After Creation
Current business flow does not support editing Order contents after successful Order creation।
Therefore do not add:
order.addItem(...);
order.removeItem(...);
order.changeQuantity(...);
just because they seem natural for a shopping cart।
An Order is not a Cart।
Order Is Not a Cart
This distinction matters।
A Cart typically supports:
add item
remove item
change quantity
wait before checkout
Our Order is created only after the Create Order workflow successfully validates:
Products
Inventory
prices
quantities
So after construction, item composition is stable।
Why This Simplifies the Model
Immutable item composition means:
historical total stable
inventory consumption matches recorded quantities
payment amount stable
cancellation knows what Inventory to restore
If Order items could mutate later, all of those workflows would become significantly more complex।
No current requirement needs that complexity।
Order Ownership Behaviour
Order can expose:
public boolean belongsTo(
CustomerId customerId
) {
return this.customerId.equals(
customerId
);
}
This is local domain information।
Order knows its owner।
But deciding whether a current caller may perform an action remains UseCase/security responsibility।
Order Read Methods
A reasonable initial read API:
public OrderId id() {
return id;
}
public CustomerId customerId() {
return customerId;
}
public List<OrderItem> items() {
return items;
}
public OrderStatus status() {
return status;
}
public BigDecimal total() {
...
}
No unrestricted setters।
A Concrete Initial Order Model
Conceptually:
package io.liveklass.ordermanagement.order.domain;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
public Order(
OrderId id,
CustomerId customerId,
List<OrderItem> items
) {
this.id = Objects.requireNonNull(
id,
"Order ID is required"
);
this.customerId =
Objects.requireNonNull(
customerId,
"Customer ID is required"
);
requireItems(items);
requireUniqueProducts(items);
this.items = List.copyOf(items);
this.status = OrderStatus.UNPAID;
}
public OrderId id() {
return id;
}
public CustomerId customerId() {
return customerId;
}
public List<OrderItem> items() {
return items;
}
public OrderStatus status() {
return status;
}
public boolean belongsTo(
CustomerId customerId
) {
return this.customerId.equals(
customerId
);
}
public BigDecimal total() {
return items.stream()
.map(OrderItem::total)
.reduce(
BigDecimal.ZERO,
BigDecimal::add
);
}
private static void requireItems(
List<OrderItem> items
) {
if (
items == null ||
items.isEmpty()
) {
throw new IllegalArgumentException(
"Order must contain at least one item"
);
}
if (items.stream().anyMatch(
Objects::isNull
)) {
throw new IllegalArgumentException(
"Order items cannot contain null"
);
}
}
private static void requireUniqueProducts(
List<OrderItem> items
) {
long uniqueProductCount =
items.stream()
.map(OrderItem::productId)
.distinct()
.count();
if (
uniqueProductCount !=
items.size()
) {
throw new IllegalArgumentException(
"Order cannot contain duplicate products"
);
}
}
}
Lifecycle methods will be expanded in the next lesson।
What About createdAt?
Order history will eventually need creation time।
A likely field is:
private final Instant createdAt;
Why is this domain/application state useful?
Because customers/admins need to understand and order historical Orders।
But there are two questions:
Who supplies the time?
How do we test it deterministically?
We should avoid simply scattering:
Instant.now()
through domain construction if testability matters।
Time as an Input
One possible direction:
public Order(
OrderId id,
CustomerId customerId,
List<OrderItem> items,
Instant createdAt
) {
}
Then the UseCase/application boundary supplies creation time।
This makes the Order deterministic।
But we don't need to fully implement time handling until the Order model/persistence needs it।
Avoid LocalDateTime Without Thinking About Semantics
For a backend event timestamp, Instant is often a strong choice because it represents an absolute moment in time।
But exact API display/timezone formatting is a transport concern।
We can keep:
absolute creation timestamp
in application/domain state and format it later for clients।
OrderId Strategy Is Still Deferred
Like ProductId, we haven't chosen:
UUID
database sequence
ULID
other strategy
So conceptual:
public record OrderId(
String value
) {
}
may demonstrate typed identity without pretending the final generation strategy is decided।
Should Order Generate Its Own ID?
Avoid:
this.id = UUID.randomUUID();
inside the domain merely because it is convenient।
Maybe UUID will be the selected strategy.
Maybe database-generated IDs will be used।
ID generation is an architectural/persistence decision we have intentionally deferred।
CreateOrderUseCase Coordinates Construction
The Order should not load Products or Inventory।
Instead:
CreateOrderUseCase
coordinates creation।
Conceptually:
1. receive CustomerId + requested items
2. reject duplicate Product IDs
3. load Products
4. verify each Product exists
5. verify each Product is active
6. load Inventory
7. verify/decrease Inventory
8. capture current Product prices
9. build OrderItems
10. build Order
11. persist changes
Order construction happens only after trusted data has been prepared।
OrderItem Construction Example
Conceptually:
OrderItem item =
new OrderItem(
product.id(),
requestedQuantity,
product.price()
);
This is the critical snapshot moment:
Product current price
↓
OrderItem historical price
Do Not Pass Product Into OrderItem
Avoid:
new OrderItem(
product,
quantity
);
if OrderItem then reads Product dynamically later।
That risks coupling historical Order state to current Product state।
Better:
ProductId
+
captured unitPrice
+
quantity
OrderItem Does Not Own Product Name Currently
Should we also snapshot:
Product name
into OrderItem?
Current requirements only explicitly require purchase-time price preservation।
Historical Product naming is not yet a requirement।
Therefore do not add:
productName
snapshot automatically।
If invoices/history later need immutable historical Product names, that would be a new design decision।
Order Total Calculation Example
Suppose:
Item A
quantity = 2
unitPrice = 10.00
Item B
quantity = 3
unitPrice = 5.00
Then:
Item A total = 20.00
Item B total = 15.00
Order total = 35.00
This should come entirely from OrderItems।
No external current Product lookup is needed after creation।
Order Total Is Deterministic
Given the same OrderItems:
same quantities
same purchase-time prices
order.total() returns the same value।
That makes the Order historical state self-contained for pricing।
This is valuable for:
order history
payment amount
incident investigation
BigDecimal Arithmetic
Because we use:
BigDecimal
remember not to construct monetary values using binary floating-point constructors like:
new BigDecimal(0.1)
Prefer:
new BigDecimal("0.10")
or values obtained from trusted decimal parsing/persistence।
This avoids floating-point conversion surprises।
No Rounding Rule Yet
Order total currently consists of:
unit price × integer quantity
so if prices already have valid decimal representation, multiplication itself doesn't introduce division rounding concerns।
We still have not defined:
currency scale
tax
discount allocation
Therefore no broad rounding framework is needed yet।
Multi-Item Atomicity
Our accepted design says:
Multi-item Order creation is all-or-nothing.
Suppose three items requested:
P-100 available
P-200 available
P-300 insufficient
We must not create a partial Order containing only the first two।
Conceptually:
all validation/state changes succeed
↓
Order created
or:
any required item fails
↓
whole operation fails
This is primarily UseCase + transaction responsibility।
Domain Model Supports Atomicity but Does Not Implement Transactions
Order requires its complete item list at construction।
That helps avoid partially built Orders।
But database transaction semantics belong later around:
CreateOrderUseCase
The domain model itself does not commit or roll back PostgreSQL state।
Inventory Is Consumed at Order Creation
Our v1 rule:
Successful Order creation decreases available Inventory.
No reservation subsystem।
So:
CreateOrderUseCase
↓
Inventory.decrease(...)
for every requested Product।
If the complete operation succeeds, Order is persisted।
If it fails, transaction must later ensure state is rolled back appropriately।
Order Does Not Store "Reserved Inventory"
Avoid fields such as:
inventoryReserved
reservationExpiresAt
because we explicitly do not have an Inventory Reservation subsystem।
Order records purchased intent and lifecycle।
Inventory available quantity is decreased directly at creation।
Cancellation Later Restores Inventory
Because OrderItems preserve:
ProductId
quantity
a cancellation workflow can later know exactly what quantities should be restored।
Conceptually:
Order
↓
items
↓
for each ProductId + quantity
↓
Inventory increase
This is one reason stable immutable OrderItems are useful।
Order Does Not Restore Inventory Itself
Avoid:
order.cancel(
inventoryRepository
);
Order does not own Inventory or repository access।
Instead:
CancelOrderUseCase
↓
order.cancel()
↓
for each OrderItem
Inventory restore
UseCase coordinates the cross-capability work।
Domain Invariant vs Cross-Capability Workflow
Order protects:
item composition
ownership reference
order lifecycle
total consistency
UseCase protects/co-ordinates:
Product existence
Product active state
Inventory availability
Inventory changes
persistence
customer authorization
This division remains important।
Do Not Put Product Availability in Order Constructor
Bad:
public Order(
List<Product> products,
InventoryRepository inventoryRepository
) {
}
Now Order construction requires infrastructure and live external domain state।
Better:
UseCase validates live state
↓
constructs stable OrderItems
↓
Order only receives valid owned state
Do Not Let Order Recalculate Price From Product
Bad:
public BigDecimal total(
ProductRepository productRepository
) {
// load current product prices
}
Now historical Order total changes whenever Product prices change।
That violates our accepted design।
Order total must come from captured OrderItem prices।
Do Not Store Request DTOs Inside Order
Bad:
private List<CreateOrderItemRequest> items;
Transport DTOs are not domain state।
Order should store:
OrderItem
objects with trusted domain values।
Do Not Store Product Objects as Historical Lines
If an OrderItem keeps:
Product product
and Product is mutable, then viewing historical Order state may accidentally show current Product values।
For historical pricing, snapshot required values explicitly।
What About Product Deactivation?
Suppose Order was created successfully with Product P-100.
Later:
P-100 becomes inactive
Existing Order remains valid।
Why?
Because Order already contains:
ProductId
quantity
purchase-time unitPrice
Its lifecycle no longer depends on Product's active flag।
What About Product Deletion?
Our design avoids physical Product deletion in normal admin behaviour।
Product becomes inactive instead।
This helps preserve references and historical consistency।
OrderItem still only needs stable Product identity plus historical price।
Order Construction Should Not Be Publicly Too Permissive
Once persistence arrives, we may need to reconstruct:
PAID Order
CANCELLED Order
from database।
That is different from creating a new business Order।
A constructor that allows callers to pass any:
status
could allow invalid workflow creation।
Creation vs Reconstruction
Conceptually:
new Order
→ always UNPAID
Existing persisted Order:
may be UNPAID
may be PAID
may be CANCELLED
We may later separate:
Order.create(...)
from persistence reconstruction。
Exact implementation depends on JPA/persistence strategy।
Do not weaken business creation rules solely for ORM convenience।
Example Creation Factory
A future approach could be:
public static Order create(
OrderId id,
CustomerId customerId,
List<OrderItem> items
) {
return new Order(
id,
customerId,
items,
OrderStatus.UNPAID
);
}
with reconstruction path kept controlled।
But we don't need to finalize this now।
No Generic setStatus()
Even persistence needs should not lead us to expose:
public void setStatus(
OrderStatus status
) {
this.status = status;
}
to the whole application।
Lifecycle state should change through meaningful operations:
markPaid()
cancel()
which we will focus on in the next lesson।
No Item Setters
Avoid:
setItems(...)
after construction।
The item composition represents what was successfully ordered।
Changing it later would alter:
historical total
inventory meaning
payment amount
without a corresponding business workflow।
Equality
Order is an Entity।
Conceptually:
same OrderId
→ same Order identity
even if status changes।
As with Product, avoid auto-generating equals()/hashCode() over all fields before persistence strategy is clear।
OrderItem, being value-like and immutable, naturally benefits from value equality when implemented as a record।
Why OrderItem as record Fits Well
A record naturally gives:
immutable components
value-based equals/hashCode
clear data shape
For an owned historical line:
ProductId
quantity
unitPrice
that is a strong fit।
Is OrderItem a DTO Because It Is a Record?
No।
Its responsibility is domain state।
It belongs inside Order and protects business meaning।
Java syntax does not define architectural role।
Error Types
For model examples we may use:
IllegalArgumentException
for invalid construction।
Production flows may later benefit from business-specific failures।
For example:
DuplicateProductInOrder
InvalidOrderItemQuantity
But do not create an elaborate exception hierarchy before error-handling requirements make it useful।
Test OrderItem Independently
Useful tests:
quantity must be positive
unit price cannot be negative
subtotal is calculated correctly
Example:
@Test
void calculatesOrderItemTotal() {
OrderItem item =
new OrderItem(
productId(),
3,
new BigDecimal("10.00")
);
assertEquals(
0,
item.total().compareTo(
new BigDecimal("30.00")
)
);
}
Using compareTo() avoids BigDecimal scale equality surprises।
Test Order Construction
Important tests:
Order requires CustomerId
Order requires at least one item
Order rejects null items
Order rejects duplicate Products
new Order starts UNPAID
total is derived correctly
items cannot be mutated externally
No Spring required।
Example: Empty Order
@Test
void orderMustContainAtLeastOneItem() {
assertThrows(
IllegalArgumentException.class,
() -> new Order(
orderId(),
customerId(),
List.of()
)
);
}
Example: Duplicate Product
@Test
void orderCannotContainDuplicateProducts() {
ProductId productId = productId();
OrderItem first =
new OrderItem(
productId,
1,
new BigDecimal("10.00")
);
OrderItem second =
new OrderItem(
productId,
2,
new BigDecimal("10.00")
);
assertThrows(
IllegalArgumentException.class,
() -> new Order(
orderId(),
customerId(),
List.of(
first,
second
)
)
);
}
Example: Derived Total
@Test
void calculatesTotalFromOrderItems() {
Order order =
new Order(
orderId(),
customerId(),
List.of(
new OrderItem(
productId("P-1"),
2,
new BigDecimal("10.00")
),
new OrderItem(
productId("P-2"),
1,
new BigDecimal("5.00")
)
)
);
assertEquals(
0,
order.total().compareTo(
new BigDecimal("25.00")
)
);
}
Test Historical Price Independence
A useful application/domain-level scenario:
Product price = 10
OrderItem created at 10
Product changes to 15
Order total remains based on 10
Because OrderItem stores the historical price, this behaviour naturally follows from the model।
Don't Test Product Repository to Verify Order Total
Order total requires only its own items।
A unit test should not involve:
ProductRepository
If it does, our Order pricing model is likely coupled incorrectly to current Product state।
Current Domain Picture
We now have:
Product
├── identity
├── current price
└── active state
Inventory
├── ProductId
└── available quantity
Order
├── OrderId
├── CustomerId
├── OrderStatus
└── OrderItems
OrderItem
├── ProductId
├── quantity
└── purchase-time unit price
This separation reflects our RFC and review decisions।
Create Order Responsibility Map
Handler
receive request
obtain authenticated CustomerId
map request to application input
CreateOrderUseCase
reject duplicate Product requests
load Products
verify Product active state
load Inventory
verify/decrease Inventory
capture Product prices
construct OrderItems
construct Order
persist changes
Product
protect current Product state
Inventory
protect quantity changes
OrderItem
protect quantity and historical unit price
Order
protect composition, ownership reference,
total consistency, and lifecycle
Repository
persist/load application-owned state
The responsibilities remain distinct।
Common Mistake 1 — Order Stores Current Product Objects
Historical Order state becomes coupled to mutable Product state।
Prefer Product references plus required snapshots।
Common Mistake 2 — Client Supplies Price
Client input is not authoritative for Product pricing।
Common Mistake 3 — Mutable OrderItem Price
Historical Orders could silently change after creation।
Common Mistake 4 — Stored Mutable Total
Order total can drift away from OrderItem values।
Derive from authoritative item state in v1।
Common Mistake 5 — Duplicate Product Lines Allowed
This conflicts with our accepted Order creation rules and complicates Inventory reasoning।
Common Mistake 6 — Order as a Cart
Adding/removing/changing items after Order creation introduces behaviour that is not currently required।
Common Mistake 7 — Order Loads Products
Domain Entity should not depend on repositories to calculate historical price।
Common Mistake 8 — Order Updates Inventory Directly
Inventory is a separate capability coordinated by UseCase।
Common Mistake 9 — setStatus() Exposed
This allows lifecycle rules to be bypassed।
Common Mistake 10 — OrderItem Gets Independent Identity Without Need
A database row key does not automatically imply a domain-level OrderItem identity।
Order Modeling Checklist
Before considering the model healthy, ask:
Can an Order exist without a customer?
Can it exist without items?
Can duplicate Products appear?
Can Order Items change after creation?
Can purchase-time prices be overwritten?
Can total disagree with items?
Can caller choose initial status?
Does Order depend on current Product state?
Does Order call repositories?
Does it own Inventory changes?
Can historical Order pricing survive Product changes?
If these answers align with our design, the model is on the right path।
Engineering Principle
The core principle:
An Order should be a stable historical business record built from trusted server-side state, not a mutable copy of the client's request.
Another:
OrderItem captures immutable purchase-time facts; Product continues to represent current catalog state.
And:
Order owns composition and lifecycle, while UseCases coordinate Product, Inventory, persistence, authentication, and external systems around it.
Summary
In this lesson, we learned that:
- Order is a domain Entity with stable identity.
- OrderItem is an owned value-like domain object in the current design.
- Every Order belongs to a stable
CustomerId. - Every Order must contain at least one item.
- OrderItems must have positive quantity and non-negative purchase-time unit price.
- OrderItem is a good immutable Java
recordcandidate. - OrderItem can calculate its own subtotal from quantity and unit price.
- The same Product cannot appear more than once in an Order.
- Duplicate Product requests should be rejected early by
CreateOrderUseCase, while Order can also protect the invariant. - New Orders start in
UNPAID. - Client input must not determine Order status.
- Order total is derived from OrderItems rather than independently mutable state.
- Product current price and OrderItem purchase-time price represent different facts.
- Product price changes must not alter existing Order totals.
- Order item composition remains immutable after successful Order creation.
- Order is not a shopping Cart and does not support add/remove/change-item operations in v1.
- Order does not load Products or repositories.
- Order does not update Inventory directly.
- CreateOrderUseCase coordinates Product, Inventory, OrderItem construction, Order construction, and persistence.
- Successful Order creation decreases Inventory directly; there is no reservation subsystem.
- Multi-item Order creation is all-or-nothing.
- Cancellation will later use immutable OrderItems to know which Inventory quantities to restore.
- Typed
OrderId,CustomerId, andProductIdcan improve semantic clarity, while exact ID generation strategy remains deferred. - Creation and persistence reconstruction are conceptually different and may require different construction paths later.
- Domain tests should verify Order and OrderItem invariants without Spring or database infrastructure.
Next lesson:
Modeling Order State
There we will focus specifically on the UNPAID → PAID and UNPAID → CANCELLED lifecycle, implement markPaid() and cancel() correctly, reject invalid transitions, and establish why state transitions should live in the Order domain rather than being scattered across Handlers or UseCases.