Domain Modeling
Turning Business Concepts into Domain Models
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
longcents. - New Products begin active.
- v1 supports Product deactivation but does not introduce a reactivation workflow.
- Inventory is identified by
ProductId; noInventoryIdexists. - 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, andCANCELLED. - New Orders always begin
UNPAID. PAIDandCANCELLEDare terminal in v1.- Order exposes
cancel()andmarkPaid()instead of a public generic status setter. - OrderItem stores
ProductId, positive quantity, and historicalunitPriceCents. - OrderItem has no independent Domain ID.
- Product's current price and OrderItem's historical price represent different business facts.
- Money uses
longcents rather thanBigDecimal,double, orfloat. - 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
createdAtis anInstant. - 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.
