Domain Modeling
Avoiding Anemic Domain Models
আপনি একটি free preview lesson দেখছেন।
এখন পর্যন্ত আমরা আলাদাভাবে model করেছি:
Product
Inventory
Order
OrderItem
CustomerId
OrderStatus
এবং বারবার একটি pattern ব্যবহার করেছি:
product.changePrice(...);
inventory.decrease(...);
order.cancel();
order.markPaid();
এগুলো শুধু naming preference নয়।
এগুলোর পেছনে একটি গুরুত্বপূর্ণ design principle আছে:
যে object কোনো business state own করে, সেই state-এর গুরুত্বপূর্ণ rules যতটা সম্ভব সেই object-এর কাছেই protect করা উচিত।
এই principle ignore করলে আমরা সহজেই এমন codebase তৈরি করি যেখানে domain classes আছে, কিন্তু domain behaviour নেই।
এই ধরনের model-কে সাধারণত বলা হয়:
Anemic Domain Model
এই lesson-এর goal:
Domain object-কে giant data bag-এ পরিণত না করে meaningful behaviour এবং invariants সঠিক জায়গায় রাখা—without pushing every workflow into Entities.
What Is an Anemic Domain Model?
একটি anemic domain model সাধারণত এমন দেখায়:
public class Order {
private OrderStatus status;
private List<OrderItem> items;
private CustomerId customerId;
public OrderStatus getStatus() {
return status;
}
public void setStatus(
OrderStatus status
) {
this.status = status;
}
public List<OrderItem> getItems() {
return items;
}
public void setItems(
List<OrderItem> items
) {
this.items = items;
}
}
Class-এর নাম Order।
Fields-ও domain-এর।
কিন্তু business behaviour কোথায়?
বাইরে।
কোনো অন্য class decide করছে:
Order কখন cancel হবে
কখন paid হবে
total কীভাবে calculate হবে
items valid কিনা
Order নিজে শুধু mutable data ধরে রাখছে।
A Giant UseCase With an Anemic Order
Suppose cancellation implementation:
public void execute(
OrderId orderId,
CustomerId customerId
) {
Order order =
orderRepository
.findById(orderId)
.orElseThrow();
if (
!order.getCustomerId()
.equals(customerId)
) {
throw new OrderAccessDeniedException();
}
if (
order.getStatus() !=
OrderStatus.UNPAID
) {
throw new IllegalStateException(
"Order cannot be cancelled"
);
}
order.setStatus(
OrderStatus.CANCELLED
);
for (
OrderItem item :
order.getItems()
) {
Inventory inventory =
inventoryRepository
.findByProductId(
item.getProductId()
);
inventory.setQuantity(
inventory.getQuantity()
+ item.getQuantity()
);
inventoryRepository.save(
inventory
);
}
orderRepository.save(order);
}
এই code-এর কিছু responsibility UseCase-এ থাকা উচিত।
For example:
load Order
verify current customer ownership
coordinate Inventory restoration
persist changes
কিন্তু এই অংশটি:
if (
order.getStatus() !=
OrderStatus.UNPAID
) {
...
}
order.setStatus(
OrderStatus.CANCELLED
);
Order-এর own lifecycle rule।
এটি UseCase-এ manually implement করা unnecessary leakage।
Better Separation
UseCase:
public void execute(
OrderId orderId,
CustomerId customerId
) {
Order order =
orderRepository
.findById(orderId)
.orElseThrow();
if (!order.belongsTo(customerId)) {
throw new OrderAccessDeniedException();
}
order.cancel();
for (OrderItem item : order.items()) {
Inventory inventory =
inventoryRepository
.findByProductId(
item.productId()
)
.orElseThrow();
inventory.increase(
item.quantity()
);
}
orderRepository.save(order);
}
এখন responsibility clearer।
CancelOrderUseCase still coordinates:
Order
Inventory
ownership
persistence
But:
Can this Order transition to CANCELLED?
is handled by:
order.cancel();
The Goal Is Not "Put Everything in the Entity"
Anemic domain model avoid করার অর্থ এই নয়:
সব business logic Entity-এর ভেতরে ঢুকিয়ে দাও।
That would create the opposite problem।
For example:
order.cancel(
currentUser,
orderRepository,
inventoryRepository,
paymentService
);
এটি ভালো design নয়।
এখন Order জানে:
authentication
repositories
Inventory
external Services
persistence
Order giant workflow coordinator হয়ে গেছে।
আমাদের architecture remains:
Handler
↓
UseCase
├── Domain
├── Repository
└── External Service
The Key Question
Rule কোথায় থাকবে তা decide করার useful question:
এই rule decide করতে কোন state প্রয়োজন?
If all required state belongs to one domain object:
put the rule close to that object
If it requires multiple domain objects, repositories, authenticated context, or external systems:
UseCase coordinates it
Example: Paid Order Cannot Be Cancelled
Required information:
Order.status
Only Order owns it।
Therefore:
order.cancel();
should protect the rule।
Example: Customer Can Cancel Only Their Own Order
Required information:
Order.customerId
+
current authenticated CustomerId
The Order can expose:
order.belongsTo(customerId);
But whether the current operation should reject unauthorized access is a UseCase/application concern।
So:
Order
→ knows ownership relationship
UseCase
→ enforces operation authorization
Example: Inventory Cannot Become Negative
Required information:
Inventory.availableQuantity
+
requested decrease amount
Inventory owns the relevant state।
So:
inventory.decrease(amount);
should enforce:
amount > 0
amount <= available quantity
Weak:
if (
inventory.quantity() >= amount
) {
inventory.setQuantity(
inventory.quantity()
- amount
);
}
Strong:
inventory.decrease(amount);
Example: Product Must Be Active for New Order
Required information:
Product.active
Product owns that state।
But the decision:
Can this Product participate in this Create Order workflow?
is coordinated by CreateOrderUseCase because the workflow also considers:
Inventory
requested quantity
Product existence
other Order items
So Product can expose its state clearly:
product.isActive();
while UseCase combines it with other conditions।
Example: Order Total
Required information:
Order.items
Order owns its OrderItems।
Each OrderItem owns:
quantity
unitPrice
So:
item.total();
and:
order.total();
are natural domain behaviours।
It would be anemic to put:
OrderCalculator.calculate(order);
for a simple calculation entirely based on Order-owned state without a real reason।
Avoid "Manager" and "Helper" Classes for Domain Behaviour
Anemic codebases often grow classes such as:
OrderManager
ProductHelper
InventoryUtils
OrderCalculator
Example:
OrderUtils.canCancel(order);
Why doesn't:
order.canCancel();
or more importantly:
order.cancel();
own that rule?
If behaviour naturally belongs to the object, moving it into a generic helper weakens encapsulation।
A Product Example
Anemic:
public class Product {
private BigDecimal price;
private boolean active;
public void setPrice(
BigDecimal price
) {
this.price = price;
}
public void setActive(
boolean active
) {
this.active = active;
}
}
Then UpdateProductUseCase:
if (
newPrice.signum() < 0
) {
throw new IllegalArgumentException();
}
product.setPrice(newPrice);
The caller owns Product's price invariant।
Better:
product.changePrice(newPrice);
Product protects:
price cannot be negative
Why This Matters Beyond Style
Suppose Product price is updated from three places:
Admin API
data import
internal migration
With public setters, every caller must remember:
price >= 0
Eventually someone forgets।
With:
product.changePrice(...)
the invariant has one natural protection point।
That reduces the number of places engineers must reason about।
Encapsulation Reduces Knowledge Duplication
Anemic model:
Caller A knows cancellation rule
Caller B knows cancellation rule
Caller C knows cancellation rule
Rich enough model:
Order knows cancellation rule
Callers simply request:
order.cancel();
This is not just code reuse।
It is:
knowledge ownership
Business Rules Are Knowledge
The rule:
Only UNPAID Orders can be cancelled
is business knowledge।
If that knowledge is scattered across:
Handler
UseCase
scheduled job
test utility
the system becomes fragile।
Domain modeling asks:
Which concept should own this knowledge?
For this rule:
Order
Not Every if Belongs in Domain
Be careful not to apply this mechanically।
For example:
if (
!order.belongsTo(customerId)
) {
throw new OrderAccessDeniedException();
}
is appropriately inside CancelOrderUseCase because the UseCase knows:
who is performing the operation
The Order does not know runtime authentication context।
Not Every Calculation Belongs in Domain
Suppose later Product browsing requires:
Product active
AND
Inventory > 0
AND
pagination
AND
filter criteria
This spans:
Product
Inventory
query/persistence concerns
A UseCase or query repository should coordinate it।
Don't force all calculations into one Entity。
Domain Behaviour Should Follow Ownership
A useful table:
| Behaviour | Natural Owner |
|---|---|
| Validate Product price | Product |
| Change Product price | Product |
| Deactivate Product | Product |
| Decrease available quantity | Inventory |
| Increase available quantity | Inventory |
| Calculate OrderItem subtotal | OrderItem |
| Calculate Order total | Order |
| Cancel Order lifecycle | Order |
| Mark Order paid | Order |
| Verify requester's ownership for an operation | UseCase |
| Load Products | Repository via UseCase |
| Coordinate Product + Inventory + Order creation | CreateOrderUseCase |
| Call Payment Provider | PaymentService via PayOrderUseCase |
This distinction prevents both anemic and overgrown domain models।
Tell, Don't Ask
Anemic code often follows this pattern:
if (
inventory.quantity() >= amount
) {
inventory.setQuantity(
inventory.quantity() - amount
);
}
The caller:
asks for state
interprets state
mutates state
A stronger model:
inventory.decrease(amount);
The caller tells Inventory what business operation to perform।
Inventory decides whether it is valid।
Another Example
Weak:
if (
order.status() ==
OrderStatus.UNPAID
) {
order.setStatus(
OrderStatus.PAID
);
}
Better:
order.markPaid();
The operation is explicit।
"Tell, Don't Ask" Is Not Absolute
Sometimes reading state is exactly what we need।
Example:
if (!product.isActive()) {
throw new ProductNotOrderableException();
}
CreateOrderUseCase needs Product state as part of a cross-domain workflow।
This is reasonable।
Don't turn a useful guideline into dogma।
Avoid Public Mutation APIs
A domain object can expose read methods:
product.price();
inventory.availableQuantity();
order.status();
That is fine।
The dangerous part is unrestricted mutation:
setPrice(...)
setQuantity(...)
setStatus(...)
when those fields have business meaning।
Setter Is Not Automatically Bad
Sometimes a state change genuinely is:
set current available quantity
Our Inventory admin requirement says:
Admin can set current available quantity to a non-negative value.
So:
inventory.setAvailableQuantity(quantity);
can be a meaningful business operation।
The issue is not the word set.
The issue is:
Does the method represent an intentional domain operation and enforce its rules?
Naming Matters Less Than Responsibility
This:
setAvailableQuantity(...)
can be good if it means:
admin-level quantity replacement
+
non-negative invariant
While:
setStatus(...)
is bad if it allows arbitrary Order lifecycle mutation।
Judge methods by business semantics, not naming rules alone।
Anemic Domain Model Often Starts From ORM
A common development sequence:
Design tables
Generate JPA entities
Generate getters/setters
Create Service classes
Put all business logic in Service
Result:
JPA Entity
→ data
Service
→ everything else
This is easy to start but often creates oversized procedural application classes।
ORM Is Not the Enemy
Using JPA does not force an anemic model।
A JPA-mapped class can still have:
order.cancel();
product.changePrice(...);
inventory.decrease(...);
The problem is letting ORM convenience define the domain API।
We will address persistence trade-offs later।
Do Not Expose Setters Just for JPA
A common excuse:
JPA needs setters.
Even when framework mapping requires certain construction/access patterns, that does not mean every mutation method must be:
public
to the entire application।
Persistence requirements and domain API should be designed deliberately।
The UseCase Still Has Real Work
After moving local rules into domain objects, CreateOrderUseCase is not empty।
It still performs meaningful coordination:
receive authenticated CustomerId
validate duplicate requested Product IDs
load Products
load Inventory
verify Product participation
decrease Inventory
capture current server-side prices
construct OrderItems
construct Order
persist state
That's substantial application logic।
A rich domain does not eliminate UseCases।
Example CreateOrderUseCase
Conceptually:
public Order execute(
CustomerId customerId,
CreateOrderCommand command
) {
requireUniqueProducts(
command.items()
);
List<OrderItem> orderItems =
new ArrayList<>();
for (
CreateOrderItem item :
command.items()
) {
Product product =
productRepository
.findById(
item.productId()
)
.orElseThrow();
if (!product.isActive()) {
throw new ProductNotOrderableException();
}
Inventory inventory =
inventoryRepository
.findByProductId(
product.id()
)
.orElseThrow();
inventory.decrease(
item.quantity()
);
orderItems.add(
new OrderItem(
product.id(),
item.quantity(),
product.price()
)
);
}
Order order =
new Order(
nextOrderId(),
customerId,
orderItems
);
return orderRepository.save(order);
}
Notice the division।
UseCase coordinates।
Domain methods protect local invariants।
What If UseCase Becomes Very Large?
A UseCase can still become too complex।
But the solution is not automatically:
split every ten lines into another Service
First ask why it is large।
Possible reasons:
too many business responsibilities in one operation
missing domain behaviour
missing external boundary
missing persistence abstraction
requirement itself is complex
Refactor according to actual responsibility।
Avoid UseCase-to-UseCase Composition for Tiny Steps
Bad:
CreateOrderUseCase
↓
ValidateProductsUseCase
↓
CheckInventoryUseCase
↓
CalculateTotalUseCase
↓
SaveOrderUseCase
Now one coherent operation is fragmented across application classes।
These are often not separate UseCases from the user's perspective।
CreateOrderUseCase should coordinate the complete operation։
UseCases Represent Application Intent
Good UseCase names correspond to meaningful actions:
CreateOrderUseCase
CancelOrderUseCase
PayOrderUseCase
AdjustInventoryUseCase
Weak pseudo-UseCases:
LoadProductUseCase
SetStatusUseCase
CalculateOneLineUseCase
unless those are genuinely standalone application operations।
Avoid "Domain Service" as an Escape Hatch
Some domain models use domain services for behaviour that does not naturally belong to one Entity or Value Object।
That concept can be legitimate।
But don't create:
OrderDomainService
and move all behaviour there merely because you don't want logic inside Order।
For our current domain, rules have clear owners।
We don't need a generic domain service layer।
External Service Terminology
In this project our convention is:
Service
→ third-party/external capability
For example:
PaymentService
Therefore internal domain/application behaviour should not be moved into:
OrderService
InventoryService
ProductService
Use:
UseCase
Domain object
Repository
according to responsibility।
Product Is Not a Passive Record
Current Product behaviour:
product.rename(...);
product.changePrice(...);
product.deactivate();
It owns:
current Product validity
A model with:
name
price
active
plus unrestricted setters would be weaker।
Inventory Is Not a Counter Bag
Current Inventory behaviour:
inventory.decrease(...);
inventory.increase(...);
inventory.setAvailableQuantity(...);
It protects:
available quantity >= 0
Anemic:
inventory.setQuantity(
inventory.quantity() - amount
);
forces every caller to know quantity rules।
OrderItem Is More Than Three Fields
OrderItem protects:
quantity > 0
unitPrice >= 0
and can calculate:
total()
Because it is immutable, historical pricing cannot be accidentally changed after creation।
Order Is Not Just Persistence State
Order protects:
non-empty items
unique Products
stable ownership
derived total
valid lifecycle
Operations:
order.markPaid();
order.cancel();
This gives Order actual domain responsibility।
Anemic Model Can Produce Contradictory State
Suppose unrestricted setters exist:
order.setItems(List.of());
order.setStatus(PAID);
order.setCustomerId(null);
Now the object can exist in impossible states।
Every function consuming Order must defensively validate it again।
Strong construction + controlled mutation reduce that burden।
Stronger Objects Reduce Defensive Code
If Order guarantees:
customerId exists
items non-empty
items valid
Products unique
status valid
then downstream code does not need to repeatedly ask:
if (
order.items() == null ||
order.items().isEmpty()
) {
...
}
A valid Order can be trusted to satisfy its invariants।
Invariants Create a Trust Boundary
Before domain construction:
input may be invalid
After successful construction:
domain object satisfies its invariants
This is powerful।
It changes how engineers reason about code।
The Domain Model Is Not Security Validation
Strong domain objects do not mean all input is safe automatically।
For example:
CustomerId belongs to caller?
still requires authenticated context।
Product existence still requires Repository।
Authorization still requires UseCase/security boundary।
Domain guarantees only the rules it actually owns।
The Domain Model Is Not Database Concurrency Control
Inventory.decrease() ensures:
one loaded Inventory object
cannot intentionally go below zero
It does not prevent two concurrent transactions from both loading the same quantity։
Similarly:
order.cancel();
does not prevent another application instance from concurrently paying the same persisted Order।
Persistence/transaction strategy remains necessary।
Rich Domain Does Not Mean "No Database Constraints"
Important invariants can be reinforced by PostgreSQL later।
For example:
inventory quantity >= 0
may be protected by:
Domain
+
database constraint
+
concurrency-safe update strategy
Each protects a different boundary।
Rich Domain Does Not Mean "No Validation in Handler"
Transport still validates:
required JSON fields
malformed values
request shape
Domain protects:
business validity
Layered validation remains appropriate।
Business Logic vs Application Logic
This distinction is useful.
Domain/business logic
Rules intrinsic to owned business state।
Examples:
Order lifecycle
Inventory quantity invariant
Product price invariant
Order total
Application logic
Coordination of a business operation।
Examples:
load three repositories
verify ownership
call PaymentService
define transaction boundary
Both are important।
They simply belong in different places।
A Useful Smell: Repeated Getter Logic
Suppose multiple UseCases contain:
if (
order.status() !=
OrderStatus.UNPAID
) {
...
}
This may signal missing domain behaviour such as:
order.cancel();
order.markPaid();
Repeated interpretation of internal state is often a sign that the owning object should express more behaviour।
Another Smell: Read-Modify-Write Everywhere
If code repeatedly does:
inventory.setAvailableQuantity(
inventory.availableQuantity()
- amount
);
that is a strong sign that:
inventory.decrease(amount);
should exist।
Another Smell: One Class Knows Every Invariant
If CreateOrderUseCase validates:
Product price
OrderItem quantity
Order item uniqueness
Order status
Inventory non-negativity
then it may be absorbing rules that belong to domain concepts।
Another Smell: Domain Objects Have 30 Setters
That usually means state ownership is weak।
Ask which state transitions are genuinely supported business operations։
Another Smell: Methods With No Behaviour
Example:
public void cancel() {
setStatus(CANCELLED);
}
while actual validation remains in UseCase।
This creates the appearance of domain behaviour without owning the invariant।
Meaningful domain methods should protect the operation they represent।
Another Smell: Domain Needs ApplicationContext
If domain objects need:
Spring Bean lookup
Repository injection
HTTP context
the model has moved too far in the opposite direction।
Domain should remain focused on business state।
Anemic vs Overloaded Domain
We want the middle ground:
Anemic Domain
←──────────────→
Overloaded Domain
Anemic:
only fields
all rules outside
Overloaded:
Entity calls DB
Entity checks JWT
Entity calls external API
Entity sends responses
Healthy:
Entity protects owned state
UseCase coordinates external responsibilities
Our Target Model
Conceptually:
CreateOrderHandler
↓
CreateOrderUseCase
├── Product
├── Inventory
├── OrderItem
├── Order
└── Repositories
Where:
Product
→ current Product rules
Inventory
→ quantity rules
OrderItem
→ purchase-time item rules
Order
→ composition + lifecycle
CreateOrderUseCase
→ cross-concept coordination
That's the balance we want।
Avoid Logic Duplication Between UseCases
Consider:
CancelOrderUseCase
and:
PayOrderUseCase
Both need Order lifecycle rules।
If those rules are only in UseCases, each operation may implement:
if (
order.status() !=
UNPAID
) {
...
}
Domain methods keep state-transition knowledge centralized।
But Different UseCases Can Still Have Different Eligibility
PayOrderUseCase may additionally check:
ownership
external payment prerequisites
while CancelOrderUseCase coordinates:
ownership
Inventory restoration
Centralizing Order state rules does not erase operation-specific workflow rules।
Don't Make Domain Objects Aware of Tickets or APIs
Avoid methods like:
order.handleCancelOrderEndpoint();
product.processAdminUpdateRequest();
Domain language should reflect business actions, not transport or project workflow։
Prefer:
order.cancel();
product.changePrice();
Do Not Model CRUD as the Domain
A common mistake:
Create
Read
Update
Delete
becomes the entire design।
But Product supports meaningful behaviour:
change price
deactivate
Order supports:
pay
cancel
Inventory supports:
consume/decrease
restore/increase
admin set quantity
Domain modelling should reflect business operations, not only CRUD verbs।
Delete Is Especially Misleading
Product business behaviour is:
deactivate
not:
delete
Order lifecycle is:
cancel
not:
delete
Using business language helps prevent persistence terminology from replacing product behaviour।
Domain Models Should Be Easy to Test
A good signal:
Product product = ...;
product.changePrice(...);
Inventory inventory = ...;
inventory.decrease(...);
Order order = ...;
order.cancel();
can all be tested using plain JUnit।
If testing Order.cancel() requires:
Spring Boot
PostgreSQL
HTTP
JWT
the domain boundary is probably too coupled।
Tests Document the Domain
A domain test such as:
@Test
void paidOrderCannotBeCancelled() {
Order order = newOrder();
order.markPaid();
assertThrows(
IllegalStateException.class,
order::cancel
);
}
communicates business behaviour clearly।
This is more expressive than testing the same rule only through a large HTTP integration test।
Don't Chase a Percentage of "Richness"
A domain class with only one meaningful method can still be good।
For example, a simple Value Object may only validate construction।
A class doesn't need many methods to avoid being anemic।
The question is:
Does it own the rules naturally associated with its state?
Some Models Are Intentionally Data-Oriented
Not every class needs rich behaviour।
Examples:
CreateOrderRequest
OrderResponse
ProviderPaymentResponse
are DTOs।
They are supposed to primarily carry data।
Calling a DTO "anemic" misses its purpose।
The term is useful mainly when a domain object that owns business state is reduced to passive data while other classes manipulate it externally।
Read Models Can Also Be Data-Oriented
A query projection for Product browsing may simply contain:
ProductId
name
price
available quantity
with no behaviour।
That's fine।
Its purpose is efficient read representation, not domain mutation।
Do not force domain patterns onto every object in the application।
Domain Model Quality Comes From Correct Responsibility
A ProductResponse with no behaviour:
good
because it is a DTO।
An Order with no lifecycle protection:
problematic
because it owns important mutable business state।
Context determines design।
A Practical Review Exercise
Suppose you see:
if (
product.price().signum() < 0
) {
throw ...
}
inside UpdateProductUseCase।
Ask:
Why could Product ever hold a negative price?
Maybe validation belongs inside Product।
Suppose you see:
order.setStatus(CANCELLED);
Ask:
Which rule is being bypassed?
Likely use:
order.cancel();
Suppose you see:
inventory.setAvailableQuantity(
inventory.availableQuantity()
- requested
);
Ask:
Should Inventory own this mutation?
Likely:
inventory.decrease(requested);
Suppose you see:
order.callPaymentProvider();
Ask:
Does Order own external integration?
No।
That belongs to:
PayOrderUseCase
→ PaymentService
A Practical Refactoring Strategy
When encountering an anemic model:
Step 1 — Identify repeated business rules
Look for repeated:
if status == ...
if quantity >= ...
if price >= ...
Step 2 — Identify state owner
Ask:
Which object owns the state required
to enforce this rule?
Step 3 — Introduce meaningful behaviour
Examples:
order.cancel();
inventory.decrease(amount);
product.changePrice(price);
Step 4 — Remove unrestricted mutation where possible
Avoid public:
setStatus
setPrice
setQuantity
that bypasses the new behaviour।
Step 5 — Keep orchestration in UseCase
Do not move:
repository calls
security context
external API calls
cross-capability transaction workflow
into the Entity।
This produces balanced responsibility।
Do Not Refactor Everything at Once
If an existing production codebase is anemic, moving every rule in one giant refactor can be risky।
A safer approach is often:
change one workflow
identify its invariant
move behaviour deliberately
add tests
remove old mutation path
Small reviewable improvements are easier to validate।
This matches the engineering workflow we established earlier।
Domain Methods Should Preserve Invariants Even if UseCase Has a Bug
Suppose a developer accidentally forgets:
check Order status
inside CancelOrderUseCase।
If the UseCase still calls:
order.cancel();
the Entity protects itself।
This is defensive domain design।
But Domain Cannot Protect Against Everything
Suppose developer forgets ownership authorization entirely।
Order.cancel() cannot know whether caller is allowed to invoke it unless identity is passed into the domain, which would mix responsibilities।
Therefore:
domain invariants
do not replace:
application authorization
transaction management
security
persistence integrity
Each boundary still matters।
A Useful Mental Model: Local Truth vs System Workflow
Domain object owns:
local truth
Example:
I am an Order.
Given my state, can I cancel?
UseCase owns:
system workflow
Example:
This authenticated customer wants to cancel this Order.
Which data must be loaded and what other state must change?
This distinction is one of the most useful ways to avoid both extremes։
Product Local Truth
My price cannot be negative.
I can be deactivated.
Inventory Local Truth
My available quantity cannot be negative.
I cannot decrease by more than I have.
OrderItem Local Truth
My quantity is positive.
My unit price is non-negative.
My subtotal is quantity × unit price.
Order Local Truth
I have at least one item.
I do not contain duplicate Product lines.
My total comes from my items.
Only UNPAID can become PAID.
Only UNPAID can become CANCELLED.
CreateOrderUseCase System Workflow
Who is the customer?
Do requested Products exist?
Are they active?
Is Inventory sufficient?
What are current server-side prices?
Can all items succeed atomically?
Persist Order and Inventory changes.
This is a clear separation of knowledge।
Domain Model and Future Changes
Suppose business later changes:
Paid Orders may be refunded.
Then likely Order lifecycle changes։
We would update the domain model and tests deliberately।
Suppose business changes:
Inventory uses reservations.
That would affect Inventory/application workflow significantly।
A well-structured domain helps localize these changes better than scattered procedural checks।
Avoid "Future-Proof" Generic Models
Don't replace simple lifecycle with:
Map<OrderStatus, Set<OrderStatus>>
allowedTransitions;
just because future states might appear।
Current behaviour is easier to understand with explicit methods।
Future requirements can justify refactoring later।
Clarity Beats Generic Abstraction
Compare:
stateMachine.transition(
order,
Transition.CANCEL
);
with:
order.cancel();
For our current domain, the second is clearer।
Generic frameworks become worthwhile only when repeated complexity actually exists।
Domain Model and Code Review
During review, ask:
Does this code interpret mutable domain state
outside the object unnecessarily?
Is there a setter bypassing a business invariant?
Does the UseCase coordinate,
or is it implementing every local rule?
Does the Entity know infrastructure it should not know?
Is a helper class stealing behaviour
that belongs to the domain?
Is this rule intrinsic to one object
or cross-capability?
These questions catch many design problems early।
Current Domain Model Summary
Our current direction:
Product
changePrice()
rename()
deactivate()
Inventory
decrease()
increase()
setAvailableQuantity()
OrderItem
validates construction
total()
Order
validates composition
total()
belongsTo()
markPaid()
cancel()
These objects are not huge।
But they own meaningful business behaviour।
That's enough।
What Does Not Belong in Them?
Still outside domain:
HTTP
JSON
Spring Security
database queries
JPA transaction management
Payment Provider protocol
logging infrastructure
metrics
Docker
configuration
A non-anemic domain does not mean framework-heavy domain।
It usually means the opposite।
Engineering Principle
The core principle:
Keep business rules close to the state they protect, while keeping cross-component orchestration in UseCases.
Another:
A domain object should not be a bag of mutable fields controlled by callers, but it also should not become a miniature application containing repositories, authentication, or external integrations.
And:
Good domain design is about ownership of knowledge—not maximizing the number of methods or design patterns.
Summary
In this lesson, we learned that:
- An anemic domain model contains domain-shaped data but little or no domain behaviour.
- Public setters often move invariant responsibility to every caller.
Order.cancel()is stronger than externally checking status and callingsetStatus().Inventory.decrease()is stronger than externally reading and rewriting quantity.Product.changePrice()centralizes Product price validity.Order.total()andOrderItem.total()naturally belong with the state they use.- Local rules should live close to the object that owns the required state.
- Cross-capability workflows belong in UseCases.
- UseCases still perform substantial work even when Entities contain meaningful behaviour.
- Repositories, authentication, transactions, and external Service calls should not be pushed into domain Entities.
- Avoid generic
Manager,Helper,Utils, or internalServiceclasses that steal natural domain responsibility. - "Tell, don't ask" is useful when callers repeatedly inspect state and then mutate the same object, but it should not be applied dogmatically.
- Read methods are not inherently bad; unrestricted mutation is the larger concern.
- A setter can still be meaningful when it represents a real business operation and protects its invariant.
- JPA does not require the domain to become anemic.
- DTOs and read projections may intentionally be data-oriented; anemic-domain concerns apply mainly to business objects that own meaningful state.
- Strong domain construction creates a useful trust boundary where successfully created objects satisfy important invariants.
- Domain invariants do not replace authorization, concurrency control, database constraints, or transport validation.
- The goal is a balanced architecture: domain objects protect local truth, while UseCases coordinate system workflows.
With this, Module 4 — Domain Modeling is complete.
Next module:
Module 5 — Building REST APIs
First lesson:
HTTP Through a Backend Engineer's Eyes
We will move from the domain model to the external API boundary and look at HTTP as an application protocol: methods, resources, status codes, headers, idempotency, safe vs unsafe operations, and how HTTP semantics influence backend API design—without turning the lesson into a memorization exercise about protocol trivia.