Domain Modeling
Turning Business Concepts into Domain Models
আপনি একটি free preview lesson দেখছেন।
এখন পর্যন্ত আমরা system নিয়ে অনেক reasoning করেছি।
আমরা জানি আমাদের Order Management Backend-এর core business concepts:
Product
Inventory
Order
Order Item
Customer Identity
Payment
আমরা responsibilities-ও define করেছি।
For example:
Product
→ current product information
→ current price
→ active/inactive state
Inventory
→ available quantity
→ quantity cannot be negative
Order
→ belongs to a customer
→ contains Order Items
→ has a lifecycle
→ can be cancelled or paid only when valid
এখন প্রশ্ন:
এই business concepts-গুলো actual Java code-এ কীভাবে model করব?
এই lesson-এর goal কোনো JPA entity বানানো নয়।
Goal:
Business concepts, state, rules, এবং behaviour-কে Java model-এ এমনভাবে represent করা যাতে code domain-এর meaning preserve করে।
Domain Modeling Is Not Table Design
Backend development-এ একটি common mistake:
Requirement দেখেই database table design শুরু করা।
For example:
products
inventory
orders
order_items
তারপর Java classes বানানো:
ProductEntity
InventoryEntity
OrderEntity
OrderItemEntity
এবং মনে করা domain modeling complete।
But database structure only answers:
Data কীভাবে persist হবে?
Domain model answers a different question:
Business concept কী এবং কোন rules তার valid state protect করে?
Start From Business Language
Requirement says:
A customer can cancel an eligible unpaid order.
This sentence contains important domain meaning:
Customer
Order
Eligibility
Unpaid
Cancellation
If we model only:
order.setStatus("CANCELLED");
we have stored data, but we have not really modeled the rule।
A stronger model might expose:
order.cancel();
and let Order decide whether cancellation is valid।
The Domain Model Should Speak the Business Language
When reading code:
order.cancel();
an engineer understands the business operation immediately।
Compare:
order.setStatus(OrderStatus.CANCELLED);
This says only:
Change a field.
It does not communicate:
Is cancellation allowed?
What states can transition?
What rule protects the change?
Good domain modeling tries to make meaningful operations visible।
Data Model vs Domain Model
Consider:
public class Order {
private Long id;
private String status;
private BigDecimal total;
// getters and setters
}
This can store Order data।
But what business behaviour does it express?
Almost none।
Any caller could potentially do:
order.setStatus("BANANA");
or:
order.setStatus("PAID");
order.setStatus("CANCELLED");
without respecting business rules।
That is primarily a data structure, not a strong domain model।
A Better Direction
Conceptually:
public class Order {
private OrderStatus status;
public void cancel() {
if (status != OrderStatus.UNPAID) {
throw new IllegalStateException(
"Order cannot be cancelled"
);
}
status = OrderStatus.CANCELLED;
}
public void markPaid() {
if (status != OrderStatus.UNPAID) {
throw new IllegalStateException(
"Order cannot be paid"
);
}
status = OrderStatus.PAID;
}
}
Now Order protects its own lifecycle।
The exact error types will improve later।
The important change is responsibility।
Domain Modeling Begins With Questions
For each business concept, ask:
What identifies it?
What state does it own?
What rules must always remain true?
What behaviour changes its state?
Which other concepts does it reference?
Which state belongs somewhere else?
These questions are more useful than asking:
Which annotations does this class need?
Let's Start With Product
From our requirements, Product needs:
stable identity
current product information
current price
active/inactive ordering state
A Product may be created, updated, or deactivated।
Inactive Product cannot be used for new Orders।
Product does not own:
Inventory quantity
historical Order Item price
Order lifecycle
This boundary matters।
A First Product Model
Conceptually:
public class Product {
private final ProductId id;
private String name;
private BigDecimal price;
private boolean active;
}
This already gives us some state।
But before adding getters and setters everywhere, ask:
What operations actually make sense?
Avoid Generic Mutation
Weak:
product.setPrice(newPrice);
product.setActive(false);
Better business-oriented operations may be:
product.changePrice(newPrice);
product.deactivate();
Why?
Because these methods can protect invariants।
Example:
public void changePrice(BigDecimal newPrice) {
if (newPrice.signum() < 0) {
throw new IllegalArgumentException(
"Price cannot be negative"
);
}
this.price = newPrice;
}
Now Product owns its own price validity rule।
Product Invariant
From design review:
Product price cannot be negative.
This is an invariant।
Meaning:
A valid Product object should never represent a negative price.
So this should not be enforced only in HTTP validation।
It should also be protected where Product state is created/changed।
Constructor Validation
Suppose:
public Product(
ProductId id,
String name,
BigDecimal price
) {
this.id = id;
this.name = name;
this.price = price;
this.active = true;
}
If price can be negative, we can construct invalid Product state।
Better:
public Product(
ProductId id,
String name,
BigDecimal price
) {
if (price.signum() < 0) {
throw new IllegalArgumentException(
"Price cannot be negative"
);
}
this.id = id;
this.name = name;
this.price = price;
this.active = true;
}
Now invalid state is harder to create।
Why "Harder to Create Invalid State" Matters
If invalid state can exist freely:
invalid object
↓
moves through application
↓
fails much later
Debugging becomes harder।
Better:
invalid input
↓
rejected near construction/change
This keeps domain assumptions stronger।
Product Active State
We established:
active Product
and:
inactive Product
have different ordering semantics।
Instead of exposing:
product.setActive(false);
prefer:
public void deactivate() {
this.active = false;
}
Potentially:
public void activate() {
this.active = true;
}
if reactivation is required by Product management behaviour।
The method name expresses intent।
Product Does Not Decide Inventory Availability
Important distinction:
Product active
does not mean:
Inventory available
A Product can be:
active
+
quantity = 0
and therefore not currently available to order।
So avoid:
product.isAvailable();
if that method only knows Product state but callers may interpret it as full order availability।
Better wording may be:
product.isActive();
or:
product.isOrderable();
only if we clearly define that this means Product state alone।
The combined "available for ordering" decision involves Product + Inventory।
Now Inventory
Inventory owns:
Product association
available quantity
Invariant:
quantity >= 0
Operations may include:
decrease quantity
increase quantity
set current quantity for admin adjustment
But operations should express rules।
Weak Inventory Model
public class Inventory {
private int quantity;
public void setQuantity(int quantity) {
this.quantity = quantity;
}
}
This allows:
inventory.setQuantity(-100);
Invalid।
It also puts responsibility on every caller to remember the rule।
Better Inventory Behaviour
Conceptually:
public class Inventory {
private final ProductId productId;
private int quantity;
public void decrease(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException(
"Amount must be positive"
);
}
if (amount > quantity) {
throw new IllegalStateException(
"Insufficient inventory"
);
}
quantity -= amount;
}
public void increase(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException(
"Amount must be positive"
);
}
quantity += amount;
}
}
Now Inventory protects basic quantity rules।
What About Admin Adjustment?
Our v1 behaviour says:
Administrator sets current available quantity to a non-negative value.
So domain could expose:
public void setAvailableQuantity(
int quantity
) {
if (quantity < 0) {
throw new IllegalArgumentException(
"Quantity cannot be negative"
);
}
this.quantity = quantity;
}
This is different from normal order consumption।
Both operations represent different intent:
decrease(...)
→ business consumption
setAvailableQuantity(...)
→ administrative adjustment
Meaningful method names help preserve that distinction।
Inventory Concurrency Is Not Solved Here
Important:
inventory.decrease(1);
can protect the in-memory object rule।
It does not automatically solve concurrent database requests।
Suppose two transactions load quantity 1 simultaneously।
Both objects may pass:
amount <= quantity
before persistence।
Concurrency-safe inventory consumption requires persistence/database strategy later।
So:
Domain invariants and database concurrency are related, but not the same problem.
Domain Model Should Not Pretend to Solve Infrastructure Problems
Inventory should know:
quantity cannot go below zero
It should not know:
SELECT FOR UPDATE
optimistic lock version
PostgreSQL transaction isolation
Those belong to persistence/application infrastructure।
This keeps boundaries clear।
Now Order
Order is more complex।
It owns:
stable identity
customer ownership
Order Items
lifecycle
total consistency
States for v1:
UNPAID
PAID
CANCELLED
Allowed transitions:
UNPAID → PAID
UNPAID → CANCELLED
Invalid:
PAID → CANCELLED
CANCELLED → PAID
This is strong domain behaviour।
Order Should Protect Its Lifecycle
Weak:
order.setStatus(OrderStatus.CANCELLED);
Any caller can bypass rules।
Better:
order.cancel();
and:
order.markPaid();
Order decides whether transition is valid।
Order State Transition Example
public void cancel() {
if (status != OrderStatus.UNPAID) {
throw new IllegalStateException(
"Only unpaid orders can be cancelled"
);
}
status = OrderStatus.CANCELLED;
}
Payment:
public void markPaid() {
if (status != OrderStatus.UNPAID) {
throw new IllegalStateException(
"Only unpaid orders can be paid"
);
}
status = OrderStatus.PAID;
}
Now state transitions are not arbitrary assignments।
Why Not Two Booleans?
Possible model:
boolean paid;
boolean cancelled;
Then states can become:
paid = true
cancelled = true
Is that valid?
No।
Now every caller must reason about combinations।
An explicit state:
OrderStatus.UNPAID
OrderStatus.PAID
OrderStatus.CANCELLED
makes valid lifecycle states clearer।
Model State That Matches the Business
Don't add:
PROCESSING
CONFIRMED
SHIPPED
DELIVERED
REFUNDED
because those sound realistic for commerce।
They are not current requirements।
Domain model should reflect current business scope, not imagined future lifecycle।
Order Item
An Order contains one or more Order Items।
Each Order Item carries:
Product reference
quantity
purchase-time unit price
Example:
public class OrderItem {
private final ProductId productId;
private final int quantity;
private final BigDecimal unitPrice;
}
Important:
unitPrice
means:
Product price at the time this Order was created.
It is not current Product price।
Order Item Is Historical State
Suppose:
Order created
Product price = €20
Later:
Product price = €25
Order Item still holds:
€20
That is why OrderItem.unitPrice is not unnecessary duplication।
It represents a different fact।
Order Item Invariants
From requirements:
quantity > 0
unit price cannot be negative
So constructor can protect these rules।
Conceptually:
public OrderItem(
ProductId productId,
int quantity,
BigDecimal unitPrice
) {
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
if (unitPrice.signum() < 0) {
throw new IllegalArgumentException(
"Unit price cannot be negative"
);
}
this.productId = productId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
Why Does Order Item Store ProductId?
Order references Product historically।
But Order does not own Product।
We established:
Order
↓ contains
Order Item
↓ references
Product
So a stable Product reference is enough for this relationship।
We do not embed the entire live Product object as owned Order state।
Composition
Order and Order Item have a strong lifecycle relationship।
Conceptually:
Order
├── OrderItem
├── OrderItem
└── OrderItem
An Order Item does not exist as an independent user-facing resource in v1।
There is no:
Create Order Item API
separate from Order।
This is a natural composition relationship।
Order Should Own Its Items
Example:
public class Order {
private final List<OrderItem> items;
}
Callers should not be able to freely mutate internal collection:
order.getItems().clear();
because then they could make an invalid zero-item Order।
Protect Collections
Instead of returning mutable internal list directly, expose an immutable/read-only view where practical।
Conceptually:
public List<OrderItem> items() {
return List.copyOf(items);
}
Now caller cannot directly mutate Order internals through the returned list।
This is encapsulation applied to collections।
An Order Must Have at Least One Item
Invariant:
Order contains at least one Order Item.
Therefore constructor/factory should reject:
new Order(
customerId,
List.of()
);
Conceptually:
if (items.isEmpty()) {
throw new IllegalArgumentException(
"Order must contain at least one item"
);
}
Again:
Invalid Order state should be hard to construct.
Duplicate Products
Requirement:
Same Product may appear at most once in one Order creation request.
Where should this be protected?
We previously decided the request/workflow should reject duplicate products before persistence।
Order construction can also protect the resulting invariant if duplicate Product Items should never exist in a valid Order।
Conceptually:
CreateOrderUseCase
↓ validates command
↓ creates valid Order Items
↓ constructs Order
The exact validation implementation comes later।
Important:
A valid Order should not casually represent duplicate lines for the same Product if the business rule forbids them.
Order Total
Our design review decided:
v1 Order total is derived from its Order Items.
Formula:
sum(
unitPrice × quantity
)
So we do not need a freely mutable:
setTotal(...)
on Order।
Example Total Behaviour
Conceptually:
public BigDecimal total() {
return items.stream()
.map(item ->
item.unitPrice()
.multiply(
BigDecimal.valueOf(
item.quantity()
)
)
)
.reduce(
BigDecimal.ZERO,
BigDecimal::add
);
}
The exact implementation may change later।
The important point is ownership:
Order
owns total consistency because it owns Order Items।
Why Not Trust the Client Total?
Client may send:
{
"total": 10
}
while server-side product prices produce:
€100
Client-controlled total cannot be authoritative।
Flow:
Product current price
↓
CreateOrderUseCase
↓
OrderItem unitPrice
↓
Order.total()
Backend determines the value։
Domain Model Does Not Load Product Prices
Should Order call:
productRepository.findById(...)
to calculate prices?
No।
That would make Order depend on persistence।
Instead:
CreateOrderUseCase
loads Product state and creates Order Items with trusted current prices।
Then Order works only with the data it owns।
Domain Model vs UseCase
This distinction is central।
UseCase coordinates
For Create Order:
identify customer
load Products
check Product state
load Inventory
check/decrease Inventory
capture prices
construct Order
persist state
Domain protects local state
Product:
valid price
active state
Inventory:
valid quantity
Order:
items
total
lifecycle
Order Item:
quantity
historical unit price
Neither responsibility should absorb the other।
Cross-Entity Rules Belong in the UseCase
Example:
Requested quantity must not exceed current Inventory.
Order alone cannot know current Inventory।
Product alone cannot know Order request।
So:
CreateOrderUseCase
coordinates that rule।
Local Entity Rules Belong in the Entity
Example:
Paid Order cannot be cancelled.
Order already owns:
status
So:
order.cancel();
is the natural place to enforce it।
A Useful Responsibility Test
Ask:
Can this rule be decided using only this object's owned state?
If yes, it may belong in the domain object।
Example:
Can Order be cancelled?
Order knows its status।
Good candidate।
If rule requires:
Product + Inventory + Customer + external provider
it likely belongs in a UseCase coordinating them।
Domain Objects Should Not Know Handlers
Never:
public class Order {
private CreateOrderHandler handler;
}
Direction is wrong।
Architecture:
Handler
↓
UseCase
↓
Domain
Domain should not depend back upward।
Domain Objects Should Not Know Repositories
Avoid:
public class Order {
private OrderRepository repository;
public void save() {
repository.save(this);
}
}
This Active Record-style approach mixes domain state and persistence in ways we have not chosen for this architecture।
Our structure is:
UseCase
↓
Order
↓
OrderRepository called by UseCase
Domain Objects Should Not Know External Services
Avoid:
order.pay(paymentService);
if that causes Order to make an external provider call।
Order can know:
Can I transition to PAID?
But:
calling payment provider
belongs to PayOrderUseCase + PaymentService।
Payment Flow
Correct conceptual separation:
PayOrderHandler
↓
PayOrderUseCase
↓
Order
↓ eligibility check
Then:
PayOrderUseCase
↓
PaymentService
↓
external provider
On confirmed success:
PayOrderUseCase
↓
order.markPaid()
Order protects transition।
UseCase coordinates the external operation।
Domain Model and Customer Identity
Do we need:
Customer
class now?
Our design says no local Customer aggregate is required for v1।
Order primarily needs:
stable customer identifier
Conceptually:
public class Order {
private final CustomerId customerId;
}
This is enough to represent ownership।
We do not invent:
Customer password
email verification
profile preferences
inside Order domain।
Typed IDs
Instead of:
Long customerId
Long productId
Long orderId
we could eventually use:
CustomerId
ProductId
OrderId
Typed identifiers can prevent mistakes such as:
findOrder(productId);
when both are plain Long।
But typed IDs also add types/code।
We have intentionally not mandated them yet।
Do Not Over-Model for Show
A common DDD-style overreaction:
OrderId
CustomerId
ProductId
Quantity
Money
OrderName
ProductName
OrderItemId
InventoryId
all introduced on day one।
Some may become useful।
But each abstraction has cost।
Rule:
Introduce a value object when it protects meaningful domain semantics or prevents repeated mistakes—not because every primitive must be wrapped.
Quantity
Could quantity simply be:
int
with validation?
Yes, for v1।
A dedicated:
Quantity
value type may be useful later if quantity rules become rich or repeated।
But we don't need it to prove we understand domain modeling।
Money
Price deserves special care।
Never use:
double
for exact monetary values।
Floating-point representation can introduce precision surprises।
For Java business applications, BigDecimal is commonly suitable for exact decimal monetary calculations।
So conceptually:
BigDecimal price
is a reasonable current direction।
BigDecimal Still Needs Discipline
BigDecimal does not automatically solve all money problems।
Questions still exist:
scale
rounding
currency
Our v1 does not include multi-currency or complex tax/discount calculations।
So don't create a complete Money framework yet।
Use exact decimal values and define additional rules when requirements require them।
Don't Add Currency If Scope Does Not Require It
If current system operates under one agreed currency context, introducing:
Money(
BigDecimal amount,
Currency currency
)
may be useful eventually, but it is not automatically required in this lesson।
We intentionally keep multi-currency out of scope।
Object Construction Matters
If a class has many setters:
Order order = new Order();
order.setCustomerId(...);
order.setStatus(...);
order.setItems(...);
there is a period where order exists but is incomplete।
Better construction can require essential state upfront।
Example:
Order order =
new Order(
orderId,
customerId,
items
);
Now object starts closer to a valid state।
Not Every Field Belongs in the Constructor
If some state is generated later by persistence, construction strategy may need adjustment।
For example:
database-generated ID
may not exist before persistence।
We will resolve exact creation/persistence model later।
The principle:
Required business state should not be optional merely to make framework mapping easier.
Factory Methods
Sometimes named factory methods communicate intent better than overloaded constructors।
Example:
Order.create(
customerId,
items
);
could communicate:
create a new unpaid Order
while a persistence reconstruction path might differ।
Do we need this now?
Not necessarily।
We will choose constructor/factory patterns when implementation details become concrete।
Domain Reconstruction
Persistence later needs to load an existing Order with state such as:
PAID
This is different from creating a brand-new Order, which should initially be:
UNPAID
This distinction can influence constructor/factory design।
But we should not solve the JPA mapping mechanics before persistence module।
For now, understand:
new business creation
and:
reconstruct persisted state
are conceptually different operations।
Avoid Setters Just for JPA Before JPA Exists
Don't compromise the domain model now because:
JPA might need setters.
We have not yet designed JPA mappings।
Modern persistence approaches provide multiple mapping options।
First build a coherent domain model।
Then integrate persistence pragmatically।
Encapsulation
A core OOP principle from the previous course now becomes practical।
Encapsulation means an object protects its internal state and exposes meaningful behaviour।
Weak:
order.status = CANCELLED;
Strong:
order.cancel();
Weak:
inventory.quantity -= quantity;
Strong:
inventory.decrease(quantity);
Meaningful methods concentrate rules।
Encapsulation Does Not Mean "No Getters"
Read access can still be needed।
For example:
order.status()
order.total()
product.price()
inventory.quantity()
The problem is unrestricted mutation, not reading state itself।
Expose what callers need, but preserve ownership of state changes।
Avoid Getter/Setter Generation as Domain Design
IDE can generate:
getX
setX
for every field।
That is not domain modeling।
Before generating a setter ask:
Which business operation requires unrestricted replacement of this field?
Often the answer is none।
Example Product Behaviour
Instead of:
product.setPrice(newPrice);
product.setActive(false);
prefer:
product.changePrice(newPrice);
product.deactivate();
Now each operation can validate and communicate business intent।
Example Inventory Behaviour
Instead of:
inventory.setQuantity(
inventory.getQuantity() - amount
);
prefer:
inventory.decrease(amount);
Now callers cannot accidentally bypass insufficient-inventory rules as easily।
Example Order Behaviour
Instead of:
order.setStatus(OrderStatus.PAID);
prefer:
order.markPaid();
The model reads like the domain।
Do We Need Inheritance?
Could we create:
BaseEntity
with:
id
createdAt
updatedAt
and make:
Product extends BaseEntity
Order extends BaseEntity
Inventory extends BaseEntity
Maybe technically possible।
But do these concepts share business behaviour because they are all one domain abstraction?
No।
This is persistence convenience, not meaningful inheritance।
We will not create BaseEntity merely to remove a few repeated fields।
Prefer Composition Over Artificial Inheritance
Order:
contains Order Items
This is a meaningful domain relationship।
Product and Inventory are associated by Product identity।
Use composition/reference where relationships are real।
Don't invent inheritance because classes share technical fields।
Domain Relationships Should Be Explicit
Our current conceptual model:
CustomerId
↓ owns
Order
↓ contains
OrderItem
↓ references
ProductId
ProductId
↓
Inventory
This is enough to reason about ownership।
It does not tell us exact JPA annotations.
That's intentional।
Domain Relationship Is Not JPA Relationship
Example:
Order Item references Product
does not automatically mean:
@ManyToOne
private Product product;
Maybe persistence will use that mapping।
Maybe it will store productId directly।
Domain meaning comes first।
Persistence representation comes later।
Don't Let ORM Decide the Domain
Weak approach:
What relationship annotation is easiest?
Then model domain around it।
Better:
What relationship does the business actually have?
Then choose persistence mapping that supports it।
Time in the Domain
Order history needs a way to order/understand when Orders were created।
So an Order creation timestamp is likely meaningful।
Conceptually:
Instant createdAt;
or another suitable Java time type।
But don't build a universal auditing framework yet।
We need:
Order creation time
because it has actual product/query meaning।
We do not automatically need:
createdBy
updatedBy
deletedBy
versionHistory
for every entity।
Use Java Time API
When timestamps become real, prefer modern Java time types such as:
Instant
LocalDate
OffsetDateTime
depending on semantics।
Avoid legacy:
java.util.Date
for new domain design unless integration constraints require it।
Exact timestamp semantics will be chosen when implemented।
Entity Identity
A Domain Entity has identity that matters across time।
Example:
Order #123
remains the same Order even if:
status changes
Similarly Product identity remains stable while:
price changes
This is one reason Product and Order are clear entities।
Inventory Identity Is More Subtle
Inventory may be identified through its Product relationship।
For v1:
one Inventory state per Product
may be enough।
We do not need to create a rich independent InventoryId unless persistence/domain needs it।
Again:
Model identity according to business meaning, not table conventions.
OrderItem Independent Identity?
Does a customer care about:
OrderItem #8272
independently of the Order?
Not in current scope।
Order Item belongs to Order lifecycle।
Database may later have an internal row ID, but that does not automatically make it a top-level domain entity with independent behaviour।
Persistence IDs and Domain IDs Can Differ
A table may need a primary key for technical reasons।
That does not necessarily mean the same identifier deserves exposure throughout the domain/API।
Keep technical persistence identity and business identity conceptually distinct।
Avoid Premature Domain Events
We could imagine:
OrderCreatedEvent
OrderCancelledEvent
InventoryChangedEvent
But current architecture has no event-driven requirement।
We do not need domain event infrastructure now।
If later Backend Engineering course introduces asynchronous workflows, the same domain can evolve।
Avoid Premature Aggregate Frameworks
We know Order + Order Items form a natural aggregate-like consistency boundary।
That does not mean we need:
AggregateRoot base class
DomainEventPublisher
AggregateRepository<T>
right now।
Use the concept to guide ownership and consistency, not to create framework ceremony।
Domain Model and Validation Layers
Suppose HTTP request quantity is:
-5
Handler-level validation may reject it before UseCase।
Should OrderItem still validate quantity?
Yes, if positive quantity is a domain invariant।
Different layers protect different boundaries।
Transport Validation
Handler may check:
required field exists
JSON shape valid
quantity has valid basic format
Domain Validation
Domain protects:
quantity > 0
price >= 0
valid Order state transition
because these must remain true regardless of whether object came from HTTP, a test, or another internal caller।
Persistence Constraint
Database may additionally enforce:
inventory quantity >= 0
where practical।
This protects stored state even if application bugs occur।
Multiple layers are not necessarily duplication when they protect different boundaries।
Don't Put Every Validation Everywhere
However, avoid blindly repeating every rule in:
Handler
UseCase
Domain
Repository
Database
Decide which boundary actually owns the rule।
Example:
JSON field required
doesn't belong in Order domain।
Example:
PAID Order cannot be cancelled
doesn't belong only in Handler।
Domain Exceptions
Our examples currently use:
IllegalArgumentException
IllegalStateException
for simplicity।
As application evolves, we may introduce domain-specific failures such as:
InvalidOrderState
InsufficientInventory
if they improve clarity and error handling।
We won't design an exception hierarchy prematurely।
Error Type Should Communicate Meaning
This:
IllegalStateException
is fine while demonstrating the rule।
But production application may need to distinguish:
business rejection
from:
programming bug
because Handler/API needs to map expected failures appropriately।
That design comes as workflows/API mature।
Keep Domain Framework-Light
Our domain model ideally remains understandable without Spring knowledge।
For example:
Order order = ...;
order.cancel();
should work in a plain JUnit test।
No Application Context needed।
This gives us:
fast tests
clear business behaviour
less framework coupling
Spring Around the Domain
Application composition:
Spring Beans
↓
Handler
↓
UseCase
During workflow:
UseCase
↓
Product
Inventory
Order
OrderItem
Spring manages application collaborators।
Domain objects remain normal Java objects।
Domain Modeling Is Iterative
We will not design the perfect final Order model today।
As we implement:
persistence
REST API
transactions
payment integration
we may learn that some domain model needs adjustment।
That's normal।
The goal is:
Start with a model that reflects current confirmed behaviour and evolve it deliberately.
Don't Optimize for Hypothetical Future Features
Order might eventually have:
shipping
returns
refunds
discounts
fulfilment
But not now।
If we add fields/states today for those features:
domain complexity
appears before business value।
Keep v1 model focused।
Our Initial Domain Direction
From current requirements, a reasonable conceptual model is:
Product
├── ProductId
├── name
├── price
└── active
Inventory
├── ProductId
└── availableQuantity
Order
├── OrderId
├── CustomerId
├── OrderStatus
├── createdAt
└── OrderItems
OrderItem
├── ProductId
├── quantity
└── purchaseTimeUnitPrice
Exact field types and persistence details will evolve।
Responsibilities
Product
Owns:
current product information
current price
active/inactive state
Protects:
price cannot be negative
Inventory
Owns:
available quantity
Protects:
quantity cannot be negative
Supports:
decrease
increase
administrative quantity adjustment
Order
Owns:
customer ownership reference
Order Items
Order lifecycle
total consistency
Protects:
at least one item
valid state transitions
Order Item
Owns:
Product reference
quantity
purchase-time price
Protects:
quantity positive
unit price non-negative
What the UseCase Still Owns
Even with good domain objects, CreateOrderUseCase remains responsible for coordination।
Conceptually:
CreateOrderUseCase
1. receive authenticated CustomerId
2. validate duplicate Product requests
3. load Products
4. confirm Product state
5. load Inventory
6. confirm/decrease quantities
7. capture current Product prices
8. create Order Items
9. create Order
10. persist changes
Domain modeling does not eliminate UseCases।
It makes the objects being coordinated stronger।
What the Handler Still Owns
CreateOrderHandler remains responsible for boundary concerns:
receive request
transport validation
obtain authenticated identity
map request to UseCase input
map result/error to response
Domain modeling does not move HTTP logic inward।
What Repository Still Owns
Repositories:
load state
save state
execute persistence-specific queries
They do not decide:
whether paid Order can be cancelled
because that is domain behaviour।
Example End-to-End Separation
Request:
POST /orders
Conceptually:
CreateOrderHandler
↓
CreateOrderUseCase
↓
ProductRepository
↓
Product
Then:
InventoryRepository
↓
Inventory.decrease(...)
Then:
new OrderItem(...)
↓
new Order(...)
↓
OrderRepository.save(...)
Each part has a clear role।
A Domain Modeling Checklist
When creating a domain class, ask:
What business concept does this represent?
What identifies it?
What state does it own?
Which state does it NOT own?
What invariants must always hold?
Which state changes have meaningful business names?
Can callers bypass those rules through setters?
Does this class know framework/infrastructure details?
Am I modeling a current requirement or an imagined future feature?
Can I test its important behaviour with plain Java?
Common Mistake 1 — JPA First
Starting with:
@Table
@Column
@ManyToOne
before understanding business ownership।
Persistence comes later।
Common Mistake 2 — Getter/Setter Model
A class with only fields + getters + setters may expose all state mutation without protecting rules।
Use meaningful behaviour where it exists।
Common Mistake 3 — Business Logic Only in UseCase
If UseCase manually checks and mutates every field:
if (order.getStatus() == UNPAID) {
order.setStatus(CANCELLED);
}
Order becomes anemic।
Prefer:
order.cancel();
Common Mistake 4 — Everything in Domain Objects
The opposite mistake:
order.loadProduct();
order.decreaseInventory();
order.callPaymentProvider();
order.save();
Now domain entity coordinates infrastructure।
UseCase should handle cross-component workflows।
Common Mistake 5 — Future State Explosion
Don't add:
SHIPPED
REFUNDED
RETURNED
before requirements exist।
Common Mistake 6 — Artificial Base Classes
Avoid BaseEntity inheritance purely for repeated persistence fields।
Common Mistake 7 — Primitive Wrappers Everywhere
Not every int, Long, or String needs a value-object class immediately।
Add abstraction when it protects useful meaning।
Common Mistake 8 — Mutable Collections Exposed
Avoid giving callers direct mutation access to Order's internal items।
Common Mistake 9 — Domain Depends on Spring
Domain business behaviour should not require ApplicationContext, HTTP classes, or external clients।
Common Mistake 10 — Confusing Current and Historical State
Remember:
Product.price
→ current price
OrderItem.unitPrice
→ historical purchase-time price
They intentionally coexist।
How This Connects to Our Earlier Design
We are now turning this conceptual model:
Customer Identity
↓
Order
↓
Order Item
↓
Product
↓
Inventory
into Java objects while preserving ownership boundaries।
No architecture change has occurred।
We are implementing the design we already reviewed।
What We Are Not Doing Yet
Not yet:
JPA mappings
Flyway schema
REST DTO details
transaction annotations
inventory locking
payment persistence
Those will be introduced when their respective lessons require them।
This lesson is about the business model first।
Engineering Principle
The core principle:
A domain model should represent business meaning, valid state, and meaningful behaviour—not merely mirror database columns.
Another:
Put rules on the object that owns the state required to enforce them, and use UseCases to coordinate rules that span multiple concepts.
And:
Make invalid business state harder to create rather than relying on every caller to remember every rule.
Summary
In this lesson, we learned that:
- Domain modeling is different from database table design.
- Business language should drive the initial Java model.
- Product, Inventory, Order, and Order Item have distinct ownership responsibilities.
- Product owns current product state and price.
- Inventory owns available quantity.
- Order owns its lifecycle, items, customer reference, and total consistency.
- Order Item owns quantity and historical purchase-time unit price.
- Meaningful methods such as
cancel(),markPaid(),decrease(), anddeactivate()are preferable to unrestricted setters when business rules exist. - Domain objects should protect invariants at construction and mutation boundaries.
- Product prices and Order Item historical prices represent different facts.
- Order total should derive from the Order Items for v1.
- Domain objects should not load repositories, read HTTP requests, call external Services, or know Spring configuration.
- Cross-entity rules remain coordinated by UseCases.
- Local entity rules belong on the object that owns the required state.
- Domain objects generally remain plain Java objects rather than Spring Beans.
- Domain relationships should not be designed from JPA annotations.
- Database concurrency cannot be solved solely through in-memory domain validation.
- Typed IDs and value objects can be useful, but should be introduced when they provide meaningful value.
BigDecimalis preferable to floating-point types for exact monetary values.- We should avoid artificial inheritance, premature domain events, speculative states, and unnecessary abstraction.
- The goal is not a perfect final model; it is a model that accurately represents current confirmed business behaviour and can evolve deliberately.
Next lesson:
Entities, Value Objects, and DTOs
There we will distinguish three concepts that are often mixed together in Java backend applications: Domain Entities, Value Objects, and DTOs—and decide which of our Order Management concepts belong in each category.