Domain Modeling

Turning Business Concepts into Domain Models

LuettavaEsikatselu

Katselet ilmaista esikatseluoppituntia.

So far, we have spent a significant amount of time reasoning about the Order Management Backend before writing business code.

We know the core concepts:

Product

Inventory

Order

OrderItem

Customer Identity

Payment

We have also defined what each concept owns.

For example:

Product
→ current Product information
→ current price
→ active/inactive state
Inventory
→ available quantity
→ quantity cannot be negative
Order
→ belongs to a Customer
→ contains OrderItems
→ has a lifecycle
→ can be cancelled or paid only when valid

Now we reach the next question:

How do we represent these business concepts as Java objects without losing their meaning?

This lesson is not about JPA.

We are not designing database mappings yet.

The goal is:

Represent business concepts, state, rules, and behaviour in Java so that the code preserves the meaning of the Domain.


Domain Modeling Is Not Table Design

A common backend-development mistake is to start with database tables.

For example:

products

inventory

orders

order_items

Then immediately create:

ProductEntity

InventoryEntity

OrderEntity

OrderItemEntity

and consider the Domain model finished.

But database design answers:

How should data be persisted?

Domain modeling answers:

What does this business concept mean, what state does it own, and what rules keep that state valid?

These are related concerns.

They are not the same concern.


Start With Business Language

Consider this requirement:

A Customer can cancel an eligible UNPAID Order.

That sentence contains several meaningful concepts:

Customer

Order

eligibility

UNPAID

cancellation

If our model exposes only:

order.setStatus(
        OrderStatus.CANCELLED
);

we can change data.

But the code does not express the business operation.

A stronger model is:

order.cancel();

Now the code says what the business says.

And Order can decide whether cancellation is valid.


The Domain Model Should Speak the Domain Language

Compare:

order.cancel();

with:

order.setStatus(
        OrderStatus.CANCELLED
);

The second statement means:

Assign a new value to a field.

The first means:

Perform the business operation called cancellation.

That difference matters.

A meaningful Domain model makes important business behaviour visible directly in code.


Data Holder vs Domain Model

Consider:

public class Order {

    private String status;

    public String getStatus() {
        return status;
    }

    public void setStatus(
            String status
    ) {
        this.status = status;
    }
}

This class can hold data.

But callers could potentially write:

order.setStatus("BANANA");

or:

order.setStatus("PAID");
order.setStatus("CANCELLED");

without respecting Order lifecycle rules.

The object does not protect its own meaning.

It is primarily a mutable data structure.


A Better Direction

Conceptually:

public class Order {

    private OrderStatus status;

    public void cancel() {
        if (
                status !=
                OrderStatus.UNPAID
        ) {
            throw new OrderNotCancellable();
        }

        status =
                OrderStatus.CANCELLED;
    }

    public void markPaid() {
        if (
                status !=
                OrderStatus.UNPAID
        ) {
            throw new OrderNotPayable();
        }

        status =
                OrderStatus.PAID;
    }
}

Now Order protects its own lifecycle.

The important improvement is not the exception class.

The important improvement is:

responsibility

Order state cannot be changed arbitrarily.


Questions to Ask When Modeling a Domain Concept

For each business concept, ask:

What identifies it?

What state does it own?

What state does it not own?

What rules must always remain true?

Which operations meaningfully change its state?

Which other concepts does it reference?

Which rules can it decide using only
its own state?

Which rules require coordination
with other concepts?

These questions are much more useful than beginning with:

Which JPA annotations should this class have?


Product

From our confirmed requirements, Product needs:

stable identity

name

current price

active/inactive state

A Product may be:

created

updated

deactivated

An inactive Product cannot be used for a new Order.

Product does not own:

Inventory quantity

historical OrderItem price

Order lifecycle

These boundaries are important.


Product Identity

Our canonical Product identity is:

ProductId
→ UUID-backed value

Conceptually:

public record ProductId(
        UUID value
) {

    public ProductId {
        Objects.requireNonNull(
                value,
                "value"
        );
    }

    public static ProductId newId() {
        return new ProductId(
                UUID.randomUUID()
        );
    }
}

Using ProductId instead of passing raw UUIDs everywhere makes the meaning of an identifier explicit.

This decision is already established for this project.


Initial Product Shape

Conceptually:

public class Product {

    private final ProductId id;

    private String name;

    private long priceCents;

    private boolean active;
}

Notice:

priceCents
→ long

We use integer minor units for money throughout this project.

For example:

8990
→ 89.90

We do not use double, float, or authoritative BigDecimal for Product pricing.


Product Invariants

Current Product rules include:

id exists

name is valid

priceCents >= 0

A valid Product should never represent:

priceCents = -500

Therefore this rule should not exist only at the HTTP boundary.

Product itself should protect it when Product state is created or changed.


Product Creation

A new Product begins:

active = true

Conceptually:

public static Product create(
        ProductId id,
        String name,
        long priceCents
) {
    validateName(name);
    validatePrice(
            priceCents
    );

    return new Product(
            id,
            name,
            priceCents,
            true
    );
}

This communicates something important:

creating a new Product

is different from:

reconstructing an existing Product
from persistence

We will return to that distinction shortly.


Product Price Validation

Conceptually:

private static void validatePrice(
        long priceCents
) {
    if (priceCents < 0) {
        throw new IllegalArgumentException(
                "Price cannot be negative."
        );
    }
}

Zero remains valid.

We do not invent:

minimum 1 cent

maximum Product price

discount rules

currency conversion rules

without requirements.


Meaningful Product Behaviour

Instead of generic mutation:

product.setPriceCents(
        newPrice
);

product.setActive(false);

prefer operations that communicate intent:

product.changePrice(
        newPriceCents
);

product.deactivate();

These methods provide a natural place to preserve invariants.


Changing Product Price

Conceptually:

public void changePrice(
        long newPriceCents
) {
    validatePrice(
            newPriceCents
    );

    this.priceCents =
            newPriceCents;
}

The Product owns the validity of its current price.


Deactivating Product

Conceptually:

public void deactivate() {
    this.active = false;
}

For v1, deactivation is intentionally simple.

Repeated deactivation may remain harmless:

inactive
→ deactivate()
→ inactive

We are not introducing a Product reactivation workflow in v1.

If Product reactivation becomes a future requirement, we can design that behaviour then.


Product Active Does Not Mean Orderable

An important distinction is:

Product.active

and:

Inventory.availableQuantity

represent different facts.

A Product can be:

active = true

availableQuantity = 0

and therefore not currently appear in Customer Product browsing.

So avoid a Product method whose name implies that Product alone knows the complete ordering decision.

For example, this can be misleading:

product.isAvailable();

Product can safely answer:

product.isActive();

But the complete Customer-browsing rule requires:

Product active
+
Inventory > 0

That is a cross-concept decision.


Inventory

Inventory owns:

ProductId

availableQuantity

Its identity is:

ProductId

There is no separate:

InventoryId

in v1.

The invariant is:

availableQuantity >= 0

Initial Inventory Shape

Conceptually:

public class Inventory {

    private final ProductId productId;

    private int availableQuantity;
}

The Product association is explicit.

But Inventory remains a separate concept because it has its own:

lifecycle

mutation patterns

concurrency concerns

Creating Inventory

Conceptually:

public static Inventory create(
        ProductId productId,
        int availableQuantity
) {
    validateAvailableQuantity(
            availableQuantity
    );

    return new Inventory(
            productId,
            availableQuantity
    );
}

Validation:

private static void validateAvailableQuantity(
        int quantity
) {
    if (quantity < 0) {
        throw new IllegalArgumentException(
                "Available quantity cannot be negative."
        );
    }
}

Administrative Inventory Update

Our v1 administration behaviour is:

Set the exact current available quantity.

Therefore Inventory may expose:

public void setAvailableQuantity(
        int quantity
) {
    validateAvailableQuantity(
            quantity
    );

    this.availableQuantity =
            quantity;
}

This represents the Domain meaning of the administrator's:

SetInventoryUseCase

It is not an arbitrary delta-based stock-adjustment system.


Increasing Inventory

Cancellation restores Inventory that was consumed during Order creation.

A meaningful local operation may therefore be:

public void increase(
        int amount
) {
    if (amount <= 0) {
        throw new IllegalArgumentException(
                "Amount must be positive."
        );
    }

    availableQuantity =
            Math.addExact(
                    availableQuantity,
                    amount
            );
}

This protects basic in-memory quantity rules.


Decreasing Inventory

At the object level, Inventory can express:

public void decrease(
        int amount
) {
    if (amount <= 0) {
        throw new IllegalArgumentException(
                "Amount must be positive."
        );
    }

    if (
            amount >
            availableQuantity
    ) {
        throw new InsufficientInventory();
    }

    availableQuantity -=
            amount;
}

This models the local invariant correctly.

But there is an important limitation.


Domain Validation Does Not Solve Database Concurrency

Suppose:

availableQuantity = 1

Two database transactions both load that state.

Each in-memory Inventory object sees:

1

and each can independently pass:

requestedQuantity <= availableQuantity

Therefore:

inventory.decrease(1);

does not by itself prevent concurrent overselling.

The persistence strategy must protect that invariant under concurrent transactions.


Our Canonical Order-Time Inventory Strategy

We already resolved this in ADR-005.

Order-time Inventory consumption uses an atomic conditional PostgreSQL update conceptually equivalent to:

UPDATE inventory
SET available_quantity =
        available_quantity - :quantity
WHERE product_id = :productId
  AND available_quantity >= :quantity;

The Repository exposes the application capability:

consumeIfAvailable(
    ProductId,
    quantity
)

So:

The Domain describes valid Inventory state. The persistence boundary protects Inventory correctness under concurrent database access.

These are related responsibilities.

They are not the same responsibility.


Domain Objects Should Not Know SQL

Inventory should know:

availableQuantity cannot be negative

It should not know:

UPDATE ... WHERE ...

row locks

transaction isolation

EntityManager

PostgreSQL

Those concerns belong to persistence and application infrastructure.

That keeps the model focused.


Order

Order is more complex.

It owns:

OrderId

CustomerId

OrderStatus

createdAt

OrderItems

lifecycle

total consistency

The v1 states are exactly:

UNPAID

PAID

CANCELLED

Allowed transitions:

UNPAID → PAID

UNPAID → CANCELLED

Invalid transitions include:

PAID → CANCELLED

CANCELLED → PAID

PAID and CANCELLED are terminal in v1.


OrderStatus

Conceptually:

public enum OrderStatus {
    UNPAID,
    PAID,
    CANCELLED
}

We deliberately do not add:

PROCESSING

CONFIRMED

SHIPPED

DELIVERED

RETURNED

REFUNDED

because those states are not part of the current business scope.

A Domain model should represent confirmed requirements, not imagined future commerce functionality.


Order Identity

Order identity is:

OrderId
→ UUID-backed

Conceptually:

public record OrderId(
        UUID value
) {

    public OrderId {
        Objects.requireNonNull(
                value,
                "value"
        );
    }

    public static OrderId newId() {
        return new OrderId(
                UUID.randomUUID()
        );
    }
}

The application generates Order IDs.

The database does not generate them for us.


CustomerId

Customer identity is different.

Authentication is externally owned.

Our application represents Order ownership using:

CustomerId

which is a stable nonblank String-backed identifier.

Conceptually:

public record CustomerId(
        String value
) {

    public CustomerId {
        if (
                value == null ||
                value.isBlank()
        ) {
            throw new IllegalArgumentException(
                    "CustomerId cannot be blank."
            );
        }
    }
}

We deliberately do not assume the external identity is a UUID.


No Customer Aggregate

We do not introduce:

Customer

CustomerRepository

Customer registration

password

profile preferences

into this Domain.

For current Order ownership, we need only:

CustomerId

That is sufficient for v1.


OrderItem

An Order contains one or more OrderItems.

Each OrderItem owns:

ProductId

quantity

purchase-time unitPriceCents

Conceptually:

public final class OrderItem {

    private final ProductId productId;

    private final int quantity;

    private final long unitPriceCents;
}

OrderItem is effectively immutable after construction.

That matches the historical nature of an Order line.


OrderItem Does Not Have Its Own ID

There is no:

OrderItemId

in our Domain.

An OrderItem belongs to one Order.

Its persistence identity is eventually:

(order_id, product_id)

because the same Product may appear at most once in one Order.

We do not invent an independent identifier without a business need.


Historical Price

OrderItem.unitPriceCents means:

The Product price used when this Order was successfully created.

Suppose:

Monday

Product.priceCents = 2000

An Order is created.

Later:

Friday

Product.priceCents = 2500

The historical OrderItem still stores:

unitPriceCents = 2000

Both values are correct.

They represent different facts.


Current vs Historical Price

Product.priceCents
→ current catalog price
OrderItem.unitPriceCents
→ historical purchase-time price

This is intentional duplication of representation because the values have different Domain meaning.

Changing current Product price must not rewrite Order history.


OrderItem Invariants

OrderItem must protect:

quantity > 0

unitPriceCents >= 0

Conceptually:

public OrderItem(
        ProductId productId,
        int quantity,
        long unitPriceCents
) {
    Objects.requireNonNull(
            productId,
            "productId"
    );

    if (quantity <= 0) {
        throw new IllegalArgumentException(
                "Quantity must be positive."
        );
    }

    if (unitPriceCents < 0) {
        throw new IllegalArgumentException(
                "Unit price cannot be negative."
        );
    }

    this.productId =
            productId;

    this.quantity =
            quantity;

    this.unitPriceCents =
            unitPriceCents;
}

Now invalid OrderItem state is rejected near construction.


Item Total

An OrderItem can derive its own line total:

public long totalCents() {
    return Math.multiplyExact(
            unitPriceCents,
            quantity
    );
}

We use exact integer arithmetic.

If the result exceeds long capacity:

Math.multiplyExact

throws rather than silently overflowing.


Why OrderItem Stores ProductId

OrderItem references a Product historically.

But Order does not own the live Product aggregate.

Conceptually:

Order
    ↓ contains
OrderItem
    ↓ references
ProductId

A stable Product reference is enough.

We do not embed the entire live Product object as owned Order state.


Order and OrderItem Form a Strong Composition

Conceptually:

Order
├── OrderItem
├── OrderItem
└── OrderItem

An OrderItem is not an independent Customer-facing resource in v1.

There is no:

POST /order-items

separate from Order creation.

Its lifecycle belongs to the Order.


Order Owns Its Items

Conceptually:

public class Order {

    private final List<OrderItem> items;
}

Callers must not be able to mutate that internal collection freely.

Bad:

order.items()
        .clear();

That could create an invalid zero-item Order.


Protect the Item Collection

Expose a defensive immutable view:

public List<OrderItem> items() {
    return List.copyOf(
            items
    );
}

Also copy incoming collections during construction.

For example:

this.items =
        List.copyOf(
                items
        );

Now external callers cannot mutate Order internals through a shared list reference.


Order Must Contain at Least One Item

This is an invariant:

OrderItems cannot be empty.

Therefore creation should reject:

Order.create(
        orderId,
        customerId,
        List.of(),
        createdAt
);

Conceptually:

if (items.isEmpty()) {
    throw new IllegalArgumentException(
            "Order must contain at least one item."
    );
}

Again:

Invalid Domain state should be difficult to construct.


Duplicate Products

Our business rule says:

The same Product may appear at most once in one Order request.

The primary workflow check belongs in:

CreateOrderUseCase

because it is validating the requested operation before performing Product and Inventory work.

Conceptually:

CreateOrderUseCase
    ↓
validate duplicate ProductIds
    ↓
create valid OrderItems
    ↓
construct Order

Order construction may also defend its own resulting invariant.

The important principle is that a valid Order should not casually contain two lines for the same Product when the Domain explicitly forbids that state.


Order Creation vs Reconstruction

A brand-new Order and a persisted Order are not the same construction scenario.

New business creation should always produce:

status = UNPAID

Existing persisted state may legitimately contain:

UNPAID

PAID

CANCELLED

Therefore we distinguish conceptually between:

create(...)

and:

reconstitute(...)

Creating a New Order

Our canonical creation direction is:

public static Order create(
        OrderId id,
        CustomerId customerId,
        List<OrderItem> items,
        Instant createdAt
) {
    validateItems(
            items
    );

    return new Order(
            id,
            customerId,
            OrderStatus.UNPAID,
            createdAt,
            items
    );
}

The caller cannot choose:

PAID

or:

CANCELLED

when creating a brand-new Order.

New Orders are always:

UNPAID

Reconstructing an Existing Order

Persistence later needs to load existing state.

Conceptually:

public static Order reconstitute(
        OrderId id,
        CustomerId customerId,
        OrderStatus status,
        Instant createdAt,
        List<OrderItem> items
) {
    validateItems(
            items
    );

    return new Order(
            id,
            customerId,
            status,
            createdAt,
            items
    );
}

This does not mean persistence can invent invalid states.

The normal persisted state must still satisfy the Domain's structural invariants.

But reconstruction is different from new business creation.


createdAt

Order creation time has real business/query meaning.

We use:

Instant

Conceptually:

private final Instant createdAt;

The application workflow obtains the value using an injected:

Clock

For example:

Instant createdAt =
        Instant.now(clock);

Then passes it into:

Order.create(...)

The Domain object does not call:

Instant.now()

internally because doing so would hide a time dependency and make testing less deterministic.


Why Clock Belongs to the UseCase

Clock is a stable application collaborator.

CreateOrderUseCase can receive:

Clock

through constructor injection.

Then:

UseCase
→ obtains current time

Order
→ owns the resulting createdAt value

This cleanly separates:

time source

from:

business state

Order Lifecycle Behaviour

Order should expose:

cancel();

and:

markPaid();

rather than a generic public:

setStatus(...);

The model should protect legal transitions.


Cancellation

Conceptually:

public void cancel() {
    if (
            status !=
            OrderStatus.UNPAID
    ) {
        throw new OrderNotCancellable();
    }

    status =
            OrderStatus.CANCELLED;
}

Only:

UNPAID

may become:

CANCELLED

Payment Transition

Conceptually:

public void markPaid() {
    if (
            status !=
            OrderStatus.UNPAID
    ) {
        throw new OrderNotPayable();
    }

    status =
            OrderStatus.PAID;
}

Only:

UNPAID

may become:

PAID

Why Not Two Booleans?

We could model:

boolean paid;
boolean cancelled;

But then this becomes representable:

paid = true

cancelled = true

Our business rules say that state is invalid.

An explicit:

OrderStatus

expresses the legal states more clearly.


Order Total

The v1 Order total is derived from OrderItems.

There is no mutable:

setTotalCents(...)

and no independent authoritative total value in the Domain.

Formula:

Σ(
    OrderItem.unitPriceCents
    ×
    OrderItem.quantity
)

Deriving the Total

Conceptually:

public long totalCents() {
    long total = 0;

    for (
            OrderItem item :
            items
    ) {
        total =
                Math.addExact(
                        total,
                        item.totalCents()
                );
    }

    return total;
}

This uses:

Math.multiplyExact

Math.addExact

to detect overflow rather than silently produce an incorrect monetary result.


Why the Client Does Not Supply Total

Suppose the client submits:

{
  "totalCents": 1000
}

while current authoritative Product prices produce:

10000

The client value cannot be trusted.

The flow is:

Product.priceCents
        ↓
CreateOrderUseCase
        ↓
OrderItem.unitPriceCents
        ↓
Order.totalCents()

The backend determines the authoritative amount.


Order Does Not Load Product Prices

Should Order call:

productRepository.findById(...)

to calculate pricing?

No.

That would make a Domain object depend on persistence.

Instead:

CreateOrderUseCase

loads current Product state.

It captures:

Product.priceCents

into:

OrderItem.unitPriceCents

Then Order works only with state it owns.


Domain vs UseCase

This distinction is central to our architecture.

UseCase coordinates

For Order creation:

receive authenticated CustomerId

validate the request

reject duplicate ProductIds

load Products

verify Products exist

verify Products are active

obtain authoritative priceCents

consume Inventory safely

create OrderItems

obtain createdAt

create Order

persist Order

Domain objects protect their own state

Product:

valid name

non-negative price

active/inactive state

Inventory:

non-negative quantity

Order:

non-empty items

lifecycle

derived total

OrderItem:

positive quantity

non-negative historical unit price

Neither responsibility should absorb the other.


Cross-Concept Rules Belong in the UseCase

Consider:

Requested quantity must not exceed current persisted Inventory.

Order alone cannot decide that.

Product alone cannot decide that.

The rule requires:

request
+
Inventory state
+
database concurrency protection

Therefore:

CreateOrderUseCase
+
InventoryRepository

coordinate the operation.


Local Rules Belong to the Object Owning the State

Consider:

A PAID Order cannot be cancelled.

Order already owns:

status

Therefore:

order.cancel();

is the natural place to protect the rule.


A Useful Responsibility Test

Ask:

Can this rule be decided using only state owned by this object?

If yes, it is a strong candidate for Domain behaviour.

Example:

Can this Order be cancelled?

Order knows:

status

Good Domain rule.

If the rule requires:

Product

Inventory

authenticated Customer

external Payment provider

then it belongs to an application workflow coordinating those collaborators.


Domain Objects Do Not Know Handlers

Never:

public class Order {

    private CreateOrderHandler handler;
}

The dependency direction would be backwards.

Our architecture is:

Handler
    ↓
UseCase
    ↓
Domain

The Domain does not depend upward.


Domain Objects Do Not Save Themselves

Avoid:

public class Order {

    private OrderRepository repository;

    public void save() {
        repository.save(this);
    }
}

We have not chosen an Active Record architecture.

Our model is:

UseCase
    ↓
Order

UseCase
    ↓
OrderRepository

The UseCase coordinates persistence.


Domain Objects Do Not Call External Services

Avoid:

order.pay(
        paymentService
);

if Order would then perform an external network operation.

Order can decide:

Can I transition to PAID?

But calling the Payment provider belongs to:

PayOrderUseCase
+
PaymentService

Payment Flow

Conceptually:

PayOrderHandler
        ↓
PayOrderUseCase
        ↓
load Customer-owned Order
        ↓
verify it is payable

Then:

PayOrderUseCase
        ↓
PaymentService
        ↓
External Provider

On a confirmed successful result:

PayOrderUseCase
        ↓
order.markPaid()

The Domain protects the transition.

The UseCase coordinates the external operation.


Encapsulation

Encapsulation becomes practical here.

Weak:

order.status =
        OrderStatus.CANCELLED;

Stronger:

order.cancel();

Weak:

inventory.availableQuantity -=
        quantity;

Stronger:

inventory.decrease(
        quantity
);

Meaningful operations centralize rules and make the model easier to understand.


Encapsulation Does Not Mean No Read Access

Callers may still need to observe state.

For example:

order.status();

order.totalCents();

product.priceCents();

inventory.availableQuantity();

The problem is not reading state.

The problem is unrestricted mutation that bypasses ownership rules.


Avoid Setter-Driven Domain Design

An IDE can generate setters for every field.

That does not mean every field should have one.

Before adding a setter, ask:

Which business operation requires unrestricted replacement of this state?

Often, the answer is:

none

Prefer methods that express business intent.


Product Example

Instead of:

product.setPriceCents(
        newPriceCents
);

product.setActive(false);

prefer:

product.changePrice(
        newPriceCents
);

product.deactivate();

Inventory Example

Instead of:

inventory.setAvailableQuantity(
        inventory.availableQuantity()
                - amount
);

prefer:

inventory.decrease(
        amount
);

when performing an in-memory quantity decrease.

For administrator state replacement:

inventory.setAvailableQuantity(
        exactQuantity
);

is meaningful because that is the actual business operation.


Order Example

Instead of:

order.setStatus(
        OrderStatus.PAID
);

prefer:

order.markPaid();

The code now speaks the Domain language.


Typed IDs Are Part of Our Canonical Model

The original design discussion considered whether typed IDs might be worthwhile.

That decision is now settled.

We use:

ProductId
→ UUID-backed

OrderId
→ UUID-backed

CustomerId
→ String-backed

This prevents accidental mistakes such as passing:

ProductId

where:

OrderId

is required.


But We Still Avoid Primitive Wrappers Everywhere

Typed identity has real value in this Domain.

That does not mean every primitive becomes a value object.

We do not currently need:

Quantity

ProductName

OrderItemId

InventoryId

Money

simply to make the model appear sophisticated.

Each abstraction has a maintenance and learning cost.


Quantity Can Remain int

For v1:

quantity
→ int

is sufficient when the relevant Domain objects protect:

quantity > 0

or:

availableQuantity >= 0

A dedicated Quantity type can be introduced later if richer, repeated quantity semantics justify it.


Money Uses long Cents

Our canonical monetary representation is:

Java
→ long cents

PostgreSQL
→ BIGINT

For example:

2000
→ 20.00

This is intentionally different from the earlier BigDecimal direction.

We already decided that integer minor units are the authoritative representation for this project.


Why Not double or float?

Binary floating-point types can produce precision behaviour inappropriate for authoritative monetary values.

So we do not use:

double

or:

float

for Product or Order pricing.


Why Not a Money Framework Yet?

We also do not create:

Money(
    long amount,
    Currency currency
)

yet.

v1 does not contain:

multi-currency

currency conversion

tax calculations

discount calculations

rounding rules

A plain:

long priceCents

is currently enough.


Object Construction Matters

Weak:

Order order =
        new Order();

order.setCustomerId(...);
order.setStatus(...);
order.setItems(...);

The object exists in partially invalid states during construction.

Better:

Order order =
        Order.create(
                orderId,
                customerId,
                items,
                createdAt
        );

Required state exists from the moment the valid Order is created.


Required Business State Should Not Be Optional for Framework Convenience

We should not weaken the Domain model because:

JPA might want setters later.

We have not designed JPA mappings yet.

First create a coherent business model.

Then integrate persistence pragmatically.

Persistence should support the Domain—not dictate it prematurely.


Domain Relationships Are Not JPA Relationships

Our conceptual relationships include:

CustomerId
    ↓ owns
Order
    ↓ contains
OrderItem
    ↓ references
ProductId

ProductId
    ↓ identifies
Inventory

This tells us the business relationships.

It does not yet tell us whether persistence should use:

@ManyToOne

or:

raw UUID columns

Those are persistence decisions for later lessons.


Do Not Let the ORM Design the Domain

Weak approach:

Which relationship annotation is easiest?

Then build the model around that choice.

Better:

What relationship exists in the business?

Then choose persistence mappings that support that model.


No Artificial BaseEntity

We could create:

abstract class BaseEntity {

    UUID id;

    Instant createdAt;

    Instant updatedAt;
}

and make everything inherit from it.

But:

Product

Order

Inventory

do not share meaningful Domain behaviour merely because persistence may contain similar technical fields.

We will not create inheritance just to remove a few repeated fields.


Prefer Meaningful Composition

Order:

contains OrderItems

This is a real Domain relationship.

Inventory:

identified by ProductId

This is a meaningful reference.

Use composition and identity relationships when they represent the business.

Avoid artificial inheritance based on storage convenience.


Entity Identity

A Domain Entity has an identity that remains meaningful while its state changes.

For example:

OrderId = X

identifies the same Order when it moves:

UNPAID
→ PAID

Likewise, Product remains the same Product when its:

priceCents

changes.

That makes Product and Order clear Entities.


Inventory Identity

Inventory uses:

ProductId

as its identity.

There is one Inventory state per Product in v1.

We do not need a separate:

InventoryId

OrderItem Identity

OrderItem does not need an independent Domain identifier.

It belongs to Order.

Persistence later identifies a row through:

(order_id, product_id)

This is another example of letting business meaning drive identity decisions.


Time in the Domain

Order creation time matters because Order history is ordered by creation time.

Therefore:

Instant createdAt

belongs to Order.

But this does not justify creating a universal auditing system containing:

createdBy

updatedBy

deletedBy

change history

for every Domain object.

We introduce time-related state only where it has real business/query meaning.


Avoid Premature Domain Events

We could imagine:

OrderCreatedEvent

OrderCancelledEvent

InventoryChangedEvent

But our current architecture has no event-driven requirement.

We do not build:

DomainEventPublisher

AggregateRoot event infrastructure

just because such patterns exist.

If future requirements justify asynchronous workflows, the model can evolve.


Avoid Premature Aggregate Frameworks

Order and OrderItems form a natural consistency boundary.

That concept is useful.

But we do not need:

AggregateRoot base class

AggregateRepository<T>

DomainEvent collection

to prove that Order owns its Items.

Use Domain concepts to guide responsibility.

Avoid framework ceremony without a current need.


Validation Exists at Different Boundaries

Suppose an HTTP request contains:

quantity = -5

The Handler may reject it during transport validation.

Should OrderItem also protect:

quantity > 0

?

Yes.

Because positive quantity is a Domain invariant.

Different boundaries protect different concerns.


Transport Validation

Handler-level validation may protect:

required field exists

input has expected shape

UUID string is syntactically valid

quantity can be parsed

These are boundary concerns.


Domain Validation

Domain objects protect:

Product priceCents >= 0

Inventory availableQuantity >= 0

OrderItem quantity > 0

OrderItem unitPriceCents >= 0

Order lifecycle transitions

These rules must remain true regardless of whether the object originated from:

HTTP

test code

another application component

persistence reconstruction

Database Constraints Protect Stored State

Later PostgreSQL can reinforce structural invariants such as:

price_cents >= 0

available_quantity >= 0

quantity > 0

unit_price_cents >= 0

status in
UNPAID / PAID / CANCELLED

This is not pointless duplication.

Different boundaries defend the system against different classes of failure.


Do Not Repeat Every Rule Everywhere

We should still avoid blindly copying every validation into:

Handler

UseCase

Domain

Repository

Database

Ask which boundary owns the rule.

For example:

JSON field is required

belongs to the transport boundary.

But:

PAID Order cannot be cancelled

must not exist only in a Handler.

That is Order behaviour.


Domain Failures

For the final Order lifecycle semantics, meaningful failures include:

OrderNotCancellable

OrderNotPayable

Inventory workflows may produce:

InsufficientInventory

We do not need a huge inheritance tree of Domain exceptions.

Use error types when they improve meaning and allow the application boundary to classify expected business rejection correctly.


Framework-Light Domain

Our Domain should remain understandable without Spring knowledge.

For example:

Order order =
        unpaidOrder();

order.cancel();

should be testable in plain JUnit.

No:

ApplicationContext

Spring Bean

HTTP request

database

is necessary to test the Order lifecycle itself.

Benefits include:

fast tests

clear business behaviour

low framework coupling

Spring Around the Domain

Spring manages application composition:

Spring
    ↓
Handler
    ↓
UseCase

During a workflow:

UseCase
    ↓
Product

Inventory

Order

OrderItem

Spring manages reusable application collaborators.

Domain objects remain ordinary Java objects.


Domain Modeling Is Iterative

We are not trying to invent the perfect final Product or Order model in one lesson.

As we later implement:

persistence

REST API

transactions

security

Payment integration

we may discover that some modeling decisions need adjustment.

That is normal.

The goal is:

Start with a model that accurately reflects confirmed behaviour and evolve it deliberately when new evidence appears.


Do Not Optimize for Hypothetical Future Features

An ecommerce system might someday contain:

shipping

returns

refunds

discounts

fulfilment

Our Order Management Backend does not.

Adding those concepts now creates Domain complexity before the business needs them.

The v1 model remains intentionally focused.


Current Domain Direction

Our canonical model is:

Product
├── ProductId(UUID)
├── name
├── priceCents(long)
└── active
Inventory
├── ProductId
└── availableQuantity(int)
Order
├── OrderId(UUID)
├── CustomerId(String)
├── OrderStatus
├── createdAt(Instant)
└── OrderItems
OrderItem
├── ProductId
├── quantity(int)
└── unitPriceCents(long)

There is:

no InventoryId

no OrderItemId

no persisted Order total

Product Responsibility

Product owns:

current name

current priceCents

active/inactive state

It protects:

valid name

priceCents >= 0

It exposes meaningful operations such as:

changePrice(...)

deactivate()

Inventory Responsibility

Inventory owns:

availableQuantity

It protects:

availableQuantity >= 0

It may support meaningful local operations such as:

setAvailableQuantity(...)

increase(...)

decrease(...)

while concurrent Order consumption remains protected through the Repository's atomic conditional database update.


Order Responsibility

Order owns:

Customer ownership reference

OrderItems

OrderStatus

createdAt

derived total

It protects:

at least one item

valid lifecycle transitions

immutable Item collection

It exposes:

cancel()

markPaid()

totalCents()

rather than unrestricted setters.


OrderItem Responsibility

OrderItem owns:

Product reference

quantity

purchase-time unitPriceCents

It protects:

quantity > 0

unitPriceCents >= 0

and can derive:

line total

using exact arithmetic.


What CreateOrderUseCase Still Owns

A strong Domain model does not eliminate the application workflow.

CreateOrderUseCase remains responsible for coordination.

Conceptually:

1. receive authenticated CustomerId

2. validate requested Items

3. reject duplicate ProductIds

4. load Products

5. verify Products exist

6. verify Products are active

7. obtain authoritative priceCents

8. consume Inventory safely

9. create OrderItems

10. generate OrderId

11. obtain createdAt from Clock

12. create UNPAID Order

13. persist the Order

14. commit all local changes atomically

Domain modeling gives the UseCase stronger objects to coordinate.

It does not replace the UseCase.


What the Handler Still Owns

CreateOrderHandler remains responsible for:

receive HTTP request

transport validation

obtain authenticated context

map request to UseCase input

invoke CreateOrderUseCase

map application result

map expected failure to API response

Domain modeling does not move HTTP responsibility inward.


What Repository Still Owns

Repositories own:

load persistent state

save persistent state

execute persistence-specific queries

perform persistence-specific concurrency operations

Repositories do not decide:

whether a PAID Order
may be cancelled

That is Domain behaviour.


End-to-End Separation

Conceptually:

POST /api/v1/orders
        ↓
CreateOrderHandler
        ↓
CreateOrderUseCase

Then:

ProductRepository
        ↓
Product

Then:

InventoryRepository
        ↓
consumeIfAvailable(...)

Then:

OrderItem
        ↓
Order.create(...)
        ↓
OrderRepository.save(...)

Each responsibility has a clear owner.


Domain Modeling Checklist

When creating a Domain class, ask:

What business concept does this represent?

What identifies it?

What state does it own?

What state does it not own?

What invariants must always hold?

Which mutations have meaningful
business names?

Can callers bypass those rules?

Does this object depend on Spring,
HTTP, persistence, or provider details?

Am I modeling a confirmed requirement
or an imagined future feature?

Can important behaviour be tested
with plain Java?

Common Mistake — JPA First

Avoid beginning Domain modeling with:

@Table

@Column

@ManyToOne

before understanding the business relationships and ownership.

Persistence comes later.


Common Mistake — Getter/Setter Model

A class containing only fields, getters, and setters may allow callers to bypass every business rule.

Use meaningful behaviour where the Domain contains meaningful behaviour.


Common Mistake — All Business Logic in the UseCase

Avoid:

if (
        order.status() ==
        OrderStatus.UNPAID
) {
    order.setStatus(
            OrderStatus.CANCELLED
    );
}

Prefer:

order.cancel();

The Order owns its lifecycle.


Common Mistake — Everything Inside the Domain Entity

The opposite is also wrong.

Avoid:

order.loadProducts();

order.consumeInventory();

order.callPaymentProvider();

order.save();

Now one Domain Entity is coordinating persistence and infrastructure.

Cross-component workflow coordination belongs to the UseCase.


Common Mistake — Future State Explosion

Do not add:

SHIPPED

REFUNDED

RETURNED

PROCESSING

before those behaviours exist.


Common Mistake — Artificial Base Classes

Do not introduce BaseEntity solely to share technical persistence fields.


Common Mistake — Value Objects Everywhere

Typed IDs are valuable and already canonical.

But not every primitive deserves a wrapper.

Introduce additional value types when they protect meaningful semantics.


Common Mistake — Mutable Collections

Do not expose Order's internal mutable List<OrderItem> directly.

Protect aggregate state.


Common Mistake — Spring Inside the Domain

Domain behaviour should not require:

ApplicationContext

@RestController

JpaRepository

SecurityContext

WebClient

to execute.


Common Mistake — Current and Historical Price Confusion

Remember:

Product.priceCents
→ current price

while:

OrderItem.unitPriceCents
→ purchase-time historical price

Both are necessary.

They should not be replaced by one another.


How This Connects to Our Existing Architecture

We are translating our already-reviewed model:

Customer Identity
        ↓
Order
        ↓
OrderItem
        ↓
Product

ProductId
        ↓
Inventory

into Java objects.

No architecture change is occurring.

We are implementing decisions already made through:

requirements

RFC-001

ADRs

design review

What We Are Not Doing Yet

Not yet:

JPA mappings

Flyway schema

Spring Data repositories

REST DTO implementation

transaction annotations

database row locking implementation

Payment persistence

Those concerns arrive in their own lessons.

This lesson remains focused on the business model.


Engineering Principle

The core principle is:

A Domain model should represent business meaning, valid state, and meaningful behaviour—not merely mirror database columns.

Another:

Put a rule on the object that owns the state required to enforce it, and use UseCases to coordinate rules spanning multiple concepts or infrastructure boundaries.

And:

Make invalid business state difficult to construct instead of relying on every caller to remember every rule.


Summary

In this lesson, we established that:

  • Domain modeling is different from database design.
  • Business language should drive the initial Java model.
  • Product, Inventory, Order, and OrderItem own different state and rules.
  • Product owns current name, priceCents, and active state.
  • Product uses UUID-backed ProductId.
  • Product price is represented as non-negative long cents.
  • New Products begin active.
  • v1 supports Product deactivation but does not introduce a reactivation workflow.
  • Inventory is identified by ProductId; no InventoryId exists.
  • Inventory owns availableQuantity.
  • Inventory quantity cannot be negative.
  • Administrative Inventory management sets an exact current quantity.
  • Local Inventory methods can protect object invariants, but database concurrency is a separate persistence problem.
  • Concurrent Order-time Inventory consumption uses the previously accepted atomic conditional PostgreSQL strategy.
  • Order uses UUID-backed OrderId.
  • Order ownership uses String-backed CustomerId.
  • There is no local Customer aggregate in v1.
  • Order states are exactly UNPAID, PAID, and CANCELLED.
  • New Orders always begin UNPAID.
  • PAID and CANCELLED are terminal in v1.
  • Order exposes cancel() and markPaid() instead of a public generic status setter.
  • OrderItem stores ProductId, positive quantity, and historical unitPriceCents.
  • OrderItem has no independent Domain ID.
  • Product's current price and OrderItem's historical price represent different business facts.
  • Money uses long cents rather than BigDecimal, double, or float.
  • Item totals use Math.multiplyExact.
  • Order totals use Math.addExact.
  • Order total is derived rather than independently mutable or persisted.
  • Order owns its Item collection and should not expose direct mutable access.
  • An Order must contain at least one OrderItem.
  • Duplicate Products are rejected by the Create Order workflow and may also be defensively protected by Order construction.
  • New Domain creation and persistence reconstruction are conceptually different operations.
  • Order createdAt is an Instant.
  • The UseCase obtains current time from an injected Clock.
  • Domain objects do not load Repositories or call external Services.
  • Cross-concept and infrastructure-dependent rules remain coordinated by UseCases.
  • Rules that require only one object's owned state belong naturally on that Domain object.