Skip to main content

Refactoring Anemic Domain Models: Moving Logic from Services to Entities

 You have likely encountered the "Service Layer" architecture that dominates modern web development. You open a codebase, find an entity named Order, and it looks like this:

export class Order {
  public id: string;
  public items: OrderItem[];
  public total: number;
  public status: string;
  public updatedAt: Date;
}

It is a glorified database schema definition. It has no behavior, only state.

Then, you open OrderService.ts, and you find a 2,000-line procedural script containing methods like createOrdercalculateTotalcancelOrder, and validateStock. This is the Anemic Domain Model. The entity is a data structure, and the service is a transaction script.

While this pattern is common, it violates the fundamental principles of Object-Oriented Programming (OOP) and Domain-Driven Design (DDD). It leads to low cohesion (logic regarding an Order is scattered across services) and high coupling (services become dependent on the internal data structure of the entity).

The Root Cause: ORMs and Procedural Habits

Why does this happen?

  1. ORM Influence: Frameworks like TypeORM, Hibernate, or Entity Framework often encourage mapping database tables 1:1 to classes with public properties for easy hydration. We conflate persistence models with domain models.
  2. Misunderstood SoC: Developers often confuse "Separation of Concerns" with "Separation of Data and Behavior." Separating persistence (Repository) from logic (Domain) is correct. Separating data (Entity) from the logic that creates/mutates that data (Service) is Procedural Programming.

In a Rich Domain Model, the Entity is the boss. It protects its own invariants. It should be impossible to put an Entity into an invalid state.

The Fix: Encapsulation and Behavior Migration

Let's refactor a classic e-commerce scenario: Adding an item to a cart.

The "Before": Anemic Model & Bloated Service

Here is the anti-pattern. The Cart is public and dumb. The CartService handles business rules (e.g., checking item limits, recalculating totals).

// anemic/Cart.ts
export class Cart {
  // Public properties allow external mutation without checks
  constructor(
    public id: string,
    public items: CartItem[] = [],
    public totalAmount: number = 0,
    public isCheckedOut: boolean = false
  ) {}
}

// anemic/CartService.ts
import { CartRepository } from './CartRepository';
import { ProductRepository } from './ProductRepository';

export class CartService {
  constructor(
    private cartRepo: CartRepository,
    private productRepo: ProductRepository
  ) {}

  async addItem(cartId: string, productId: string, quantity: number): Promise<void> {
    const cart = await this.cartRepo.findById(cartId);
    const product = await this.productRepo.findById(productId);

    if (!cart) throw new Error("Cart not found");
    if (cart.isCheckedOut) throw new Error("Cannot modify a checked-out cart");
    
    // BUSINESS LOGIC LEAK: The service is manually manipulating the array
    const existingItem = cart.items.find(i => i.productId === productId);

    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      cart.items.push({ productId, quantity, price: product.price });
    }

    // BUSINESS LOGIC LEAK: The service is responsible for invariants
    if (cart.items.length > 20) {
      throw new Error("Cart exceeds maximum unique items limit");
    }

    // BUSINESS LOGIC LEAK: Recalculating state
    cart.totalAmount = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);

    await this.cartRepo.save(cart);
  }
}

The "After": Rich Domain Model

We will move the logic into the Cart. The Cart will enforce its own rules. The Service becomes a thin orchestration layer.

1. The Rich Entity

We use TypeScript's private access modifiers and getters to ensure external consumers (like the Service) can read state but cannot mutate it directly.

// domain/Cart.ts

// Value Object for stricter typing (optional but recommended)
import { randomUUID } from 'node:crypto';

export class Cart {
  private readonly MAX_ITEMS = 20;
  
  // State is encapsulated. 
  // We use private properties so they cannot be mutated externally.
  private _items: Map<string, CartItem>; 
  private _isCheckedOut: boolean;
  private _id: string;

  // Reconstitute from persistence or create new
  private constructor(id: string, items: CartItem[], isCheckedOut: boolean) {
    this._id = id;
    this._items = new Map(items.map(i => [i.productId, i]));
    this._isCheckedOut = isCheckedOut;
  }

  // Factory method for creating a new Cart
  public static create(): Cart {
    return new Cart(randomUUID(), [], false);
  }

  // Factory method for reconstituting from DB (Hydration)
  public static restore(id: string, items: CartItem[], isCheckedOut: boolean): Cart {
    return new Cart(id, items, isCheckedOut);
  }

  // PUBLIC BEHAVIOR --------------------------------------------

  public addItem(product: Product, quantity: number): void {
    this.ensureNotCheckedOut();
    
    if (quantity <= 0) throw new Error("Quantity must be positive");

    const existingItem = this._items.get(product.id);

    if (existingItem) {
      this.updateItemQuantity(product.id, existingItem.quantity + quantity);
    } else {
      this.addNewItem(product, quantity);
    }
    
    // Invariant check is now internal
    if (this._items.size > this.MAX_ITEMS) {
      throw new Error("Cart exceeds maximum unique items limit");
    }
  }

  public get totalAmount(): number {
    // Calculated on the fly, ensuring it is always correct based on current items
    let sum = 0;
    for (const item of this._items.values()) {
      sum += item.price * item.quantity;
    }
    return parseFloat(sum.toFixed(2)); // JavaScript float handling
  }

  // PRIVATE HELPERS --------------------------------------------

  private ensureNotCheckedOut(): void {
    if (this._isCheckedOut) {
      throw new Error("Operation failed: Cart is already checked out.");
    }
  }

  private addNewItem(product: Product, quantity: number): void {
    this._items.set(product.id, {
      productId: product.id,
      price: product.price,
      quantity
    });
  }

  private updateItemQuantity(productId: string, newQuantity: number): void {
    const item = this._items.get(productId);
    if (!item) return;
    
    item.quantity = newQuantity;
    this._items.set(productId, item);
  }

  // ACCESSORS (Read-Only Snapshot) -----------------------------
  
  public get id(): string { return this._id; }
  
  public get items(): ReadonlyArray<CartItem> {
    return Array.from(this._items.values());
  }
}

// Minimal types for the example
interface CartItem { productId: string; price: number; quantity: number; }
interface Product { id: string; price: number; }

2. The Refactored Service

Now look at the service. It no longer knows how to add an item or calculate totals. It simply loads the entity, tells it what to do, and saves it.

// application/CartService.ts
export class CartService {
  constructor(
    private cartRepo: CartRepository,
    private productRepo: ProductRepository
  ) {}

  async addItem(cartId: string, productId: string, quantity: number): Promise<void> {
    // 1. Load Data
    const cart = await this.cartRepo.findById(cartId);
    const product = await this.productRepo.findById(productId);
    
    if (!cart) throw new Error("Cart not found");
    if (!product) throw new Error("Product not found");

    // 2. Delegate to Domain (The 'Tell, Don't Ask' Principle)
    cart.addItem(product, quantity);

    // 3. Persist State
    // The repo extracts the state from the entity getters to save to DB
    await this.cartRepo.save(cart);
  }
}

Why This Works

1. Invariants are Protected

In the anemic model, a junior developer could initialize a CartService, load a cart, and manually push a 21st item into the array, bypassing the check. In the Rich model, the items array is private. The only way to modify it is via addItem, which forces the MAX_ITEMS check. The compiler enforces your business rules.

2. High Cohesion

If the business requirement for "Total Calculation" changes (e.g., adding tax logic), you go to Cart.ts. You don't have to hunt down CartServiceCheckoutService, and InvoiceService to see which one implements the math. The logic lives with the data.

3. Testability

Testing the Anemic Service requires mocking the database repositories just to test simple math logic (like total calculation).

Testing the Rich Entity requires no mocks:

// Cart.test.ts
import { describe, it, expect } from 'vitest'; // or jest
import { Cart } from './domain/Cart';

describe('Cart Domain Entity', () => {
  it('should calculate total correctly', () => {
    const cart = Cart.create();
    const productA = { id: '1', price: 100 };
    const productB = { id: '2', price: 50 };

    cart.addItem(productA, 2); // 200
    cart.addItem(productB, 1); // 50

    expect(cart.totalAmount).toBe(250);
  });

  it('should throw when adding item to checked out cart', () => {
    // Simulate restoring a checked out cart
    const cart = Cart.restore('id', [], true); 
    const product = { id: '1', price: 100 };

    expect(() => cart.addItem(product, 1)).toThrow(/already checked out/);
  });
});

Conclusion

Refactoring from Anemic to Rich models shifts your focus from "manipulating data" to "modeling behavior."

Start small. You don't need to rewrite your entire ORM layer today. Pick one Aggregate—like Cart or Order—that has complex state transitions. Make its properties private, expose behavior methods, and watch your Service layer shrink into the lean orchestration layer it was always meant to be.