Domain Modeling

Avoiding Anemic Domain Models

ReadingPreview

আপনি একটি 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:

BehaviourNatural Owner
Validate Product priceProduct
Change Product priceProduct
Deactivate ProductProduct
Decrease available quantityInventory
Increase available quantityInventory
Calculate OrderItem subtotalOrderItem
Calculate Order totalOrder
Cancel Order lifecycleOrder
Mark Order paidOrder
Verify requester's ownership for an operationUseCase
Load ProductsRepository via UseCase
Coordinate Product + Inventory + Order creationCreateOrderUseCase
Call Payment ProviderPaymentService 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 calling setStatus().
  • Inventory.decrease() is stronger than externally reading and rewriting quantity.
  • Product.changePrice() centralizes Product price validity.
  • Order.total() and OrderItem.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 internal Service classes 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.