One of the most pervasive anti-patterns in modern backend development is the Anemic Domain Model. It often starts innocently: you define your entities as simple data containers (POJOs or POCOs) to play nicely with your ORM. Then, you create a "Service Layer" to handle the business logic.
Fast forward six months. Your OrderService is 3,000 lines long. Business rules are duplicated across three different handlers. Unit testing requires mocking five different repositories just to verify a status change.
This is the Transaction Script pattern masquerading as Object-Oriented Programming.
In this guide, we will refactor a typical Service-based architecture into a Rich Domain Model. We will move logic out of the service and into the Aggregate, enforcing invariants where they belong: inside the domain object.
The Root Cause: Why Anemia Happens
Anemia usually stems from a fundamental misunderstanding of the separation of concerns. Developers often confuse Data Structures with Objects.
- Data Structures expose data and have no behavior (DTOs).
- Objects expose behavior and hide data.
When using ORMs like TypeORM, Entity Framework, or Hibernate, the default documentation often encourages public getters and setters for every column. This breaks Encapsulation.
Once the internal state of an entity is public, the entity loses control over its own lifecycle. The responsibility of maintaining data consistency (Invariants) shifts to the consumer—usually the Service layer. This leads to defensive coding, duplication, and "Shotgun Surgery" (where one business rule change requires edits in multiple files).
The Scenario: An E-Commerce Order System
Let's look at the "Before" state. We have an e-commerce context where users can add items to an order.
The "Anemic" Code (Anti-Pattern)
Here, the Order entity is just a schema definition. The OrderService holds the intelligence.
// ❌ ANEMIC ENTITY: Just a bag of data
export class Order {
public id: string;
public status: 'DRAFT' | 'CONFIRMED' | 'SHIPPED';
public items: OrderItem[];
public totalAmount: number;
public updatedAt: Date;
constructor() {
this.items = [];
this.totalAmount = 0;
this.status = 'DRAFT';
}
}
// ❌ SERVICE LAYER: Logic is separated from state
export class OrderService {
constructor(private readonly orderRepo: OrderRepository) {}
public async addItem(orderId: string, item: OrderItem): Promise<void> {
const order = await this.orderRepo.findById(orderId);
// BUSINESS RULE 1: Cannot modify confirmed orders
if (order.status !== 'DRAFT') {
throw new Error("Cannot add items to a confirmed order.");
}
// BUSINESS RULE 2: Max 10 items per order
if (order.items.length >= 10) {
throw new Error("Order limit reached.");
}
// BUSINESS RULE 3: Update totals and timestamps
order.items.push(item);
order.totalAmount += item.price * item.quantity;
order.updatedAt = new Date();
// VIOLATION: We rely on the service to remember to save
await this.orderRepo.save(order);
}
}
Why This Fails
- Leaky Abstractions: Nothing stops a developer from doing
order.status = 'SHIPPED'in a completely different part of the codebase, bypassing validation. - Testability: To test the "Max 10 items" rule, you have to instantiate the Service and mock the Repository. You cannot test the logic in isolation.
- Inconsistency: If a new developer adds a
removeItemmethod but forgets to updatetotalAmount, the database becomes inconsistent.
The Refactor: Building a Rich Aggregate
To fix this, we apply Domain-Driven Design (DDD) principles. We treat Order as an Aggregate Root. It must guarantee that it never exists in an invalid state.
Step 1: Encapsulate State
We make properties private or readonly. We remove public setters.
Step 2: Expose Behavior, Not Data
We replace operations like order.items.push() with semantic methods like order.addItem().
The "Rich" Code (Best Practice)
import { randomUUID } from 'crypto';
// Value Objects enforce micro-invariants (e.g., Price cannot be negative)
class Money {
constructor(public readonly amount: number, public readonly currency: string) {
if (amount < 0) throw new Error("Price cannot be negative");
}
add(other: Money): Money {
if (other.currency !== this.currency) throw new Error("Currency mismatch");
return new Money(this.amount + other.amount, this.currency);
}
}
export enum OrderStatus {
Draft = 'DRAFT',
Confirmed = 'CONFIRMED',
Shipped = 'SHIPPED'
}
// ✅ RICH AGGREGATE ROOT
export class Order {
// State is private. Only the Aggregate can modify it.
private _items: OrderItem[] = [];
private _status: OrderStatus = OrderStatus.Draft;
private _totalAmount: Money;
private _updatedAt: Date;
constructor(
public readonly id: string,
currency: string = 'USD'
) {
this._totalAmount = new Money(0, currency);
this._updatedAt = new Date();
}
// Public Getters for reading state (Read-Only)
get status(): OrderStatus { return this._status; }
get items(): ReadonlyArray<OrderItem> { return this._items; }
get totalAmount(): Money { return this._totalAmount; }
// ✅ BEHAVIOR: Logic lives here
public addItem(item: OrderItem): void {
this.ensureOrderIsModifiable();
if (this._items.length >= 10) {
throw new DomainError("Order limit reached (Max 10 items).");
}
this._items.push(item);
this.recalculateTotals();
this.markAsUpdated();
}
public confirm(): void {
if (this._items.length === 0) {
throw new DomainError("Cannot confirm an empty order.");
}
this._status = OrderStatus.Confirmed;
this.markAsUpdated();
}
// Internal helper to enforce invariants centrally
private recalculateTotals(): void {
let sum = 0;
for (const item of this._items) {
sum += item.price.amount * item.quantity;
}
this._totalAmount = new Money(sum, this._totalAmount.currency);
}
private ensureOrderIsModifiable(): void {
if (this._status !== OrderStatus.Draft) {
throw new DomainError("Order is already confirmed and cannot be modified.");
}
}
private markAsUpdated(): void {
this._updatedAt = new Date();
}
}
class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = "DomainError";
}
}
The Slimmed-Down Service
Now, look at how clean the application service becomes. It acts merely as an orchestrator, connecting the outside world (Controllers/API) to the Domain.
export class OrderService {
constructor(private readonly orderRepo: OrderRepository) {}
public async addItem(orderId: string, itemDTO: AddItemDTO): Promise<void> {
// 1. Load Aggregate
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error("Order not found");
// 2. Delegate to Domain
// All validation logic happens here automatically
const item = new OrderItem(itemDTO.name, new Money(itemDTO.price, 'USD'), itemDTO.quantity);
order.addItem(item);
// 3. Persist State
await this.orderRepo.save(order);
}
}
Deep Dive: Why This Is Better
1. Guaranteed Consistency (Invariants)
In the refactored code, it is literally impossible to have an Order where the totalAmount does not match the sum of the items. The recalculateTotals() method is private and called automatically whenever items change. You have eliminated an entire class of "data corruption" bugs.
2. Unit Testing is Trivial
You no longer need complex mocks to test business rules. You are testing a pure TypeScript class.
// Test Logic in Isolation without Database Mocks
test('should throw error when adding item to confirmed order', () => {
const order = new Order('123');
order.confirm(); // State transition
expect(() => {
order.addItem(mockItem);
}).toThrow("Order is already confirmed");
});
3. Cognitive Load and Ubiquitous Language
The code now reads like the business requirements. Methods are named confirm() or addItem(), not setStatus() or setItems(). A new developer can read the Order class and understand exactly what an order can and cannot do.
Common Pitfalls and Edge Cases
Moving logic to Aggregates is powerful, but you will encounter friction points.
Pure Domain vs. Infrastructure
A common mistake is injecting repositories into the Entity. Bad: order.addItem(item, repository) Good: Pass the necessary data into the method. If an order needs to check stock levels, pass the StockLevel value object into the method, or use a Domain Service if the logic involves multiple aggregates.
Persistence Leaks
ORMs often require public setters or parameter-less constructors to rehydrate objects from the database. Solution:
- Private Setters: Most modern ORMs (EF Core, TypeORM, Hibernate) can map to private fields/setters via reflection.
- Memento Pattern: Alternatively, create a generic interface
OrderStatethat the ORM maps to, and have the Domain Object export/import that state solely for persistence.
The "God Entity"
Do not dump every piece of logic into the Aggregate. If logic requires querying heavily across the database (e.g., "Check if user has bought this item in the last year"), that belongs in a Domain Service, not the Entity itself. The Entity should focus on logic regarding the data it holds in memory.
Conclusion
Refactoring an Anemic Domain Model is a shift from procedural scripting to true Object-Oriented design. By encapsulating state and exposing behavior, you create a system that is self-validating and easier to test.
Stop writing Services that manipulate helpless data containers. Empower your Aggregates to protect their own integrity. Your future self (and your QA team) will thank you.