Domain Modeling

Modeling Orders and Order Items

ReadingPreview

আপনি একটি 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 record candidate.
  • 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, and ProductId can 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.