Skip to main content

Refactoring Anemic Domain Models: Moving Logic from Services to Rich Aggregates

 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

  1. Leaky Abstractions: Nothing stops a developer from doing order.status = 'SHIPPED' in a completely different part of the codebase, bypassing validation.
  2. 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.
  3. Inconsistency: If a new developer adds a removeItem method but forgets to update totalAmount, 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:

  1. Private Setters: Most modern ORMs (EF Core, TypeORM, Hibernate) can map to private fields/setters via reflection.
  2. Memento Pattern: Alternatively, create a generic interface OrderState that 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.