open atlas
↑ Back to track
Architecture Patterns ARCH · 03 · 02

Anemic vs rich domain model

An anemic domain model puts all behavior in services and leaves domain objects as pure data bags. It is a recognized anti-pattern for complex domains because it destroys encapsulation and lets invariants drift. For simple CRUD it is often the pragmatic choice.

ARCH Middle ◷ 22 min
Level
FoundationsJuniorMiddleSenior

The B2B order platform had an Order class with twenty fields: id, status, customerId, lineItems, invoiceId, totalAmount, approvedBy, approvedAt, confirmedAt, cancelledAt, and more. The class had getters and setters for each field and nothing else. All the logic lived in OrderService. There was a method confirmOrder, a method cancelOrder, a method approveOrder, and a method applyDiscount. Three months after the platform launched, a bug was found in production: a cancelled order had been invoiced. The root cause was that confirmOrder and the billing job both changed the order’s status field directly — one via setStatus('confirmed'), one via an ORM update — without knowing about each other. The invariant “a cancelled order cannot be confirmed or invoiced” existed in comments and in the team’s heads, but nowhere in the code. Because the Order class owned no behavior, it also enforced no rules. Any piece of code in the system could set any field to any value at any time. The model was technically object-oriented — it used a class — but it was not actually encapsulating anything.

What an anemic domain model looks like

Martin Fowler coined the term “Anemic Domain Model” in 2003 to describe a pattern that had become pervasive: domain objects that are little more than named data structures — getters, setters, and no real behavior — combined with service classes that contain all the logic.

// Anemic: Order is a data bag
class Order {
  id: string;
  status: string;
  lineItems: LineItem[];
  totalAmount: number;
  customerId: string;
  approvedBy?: string;
  approvedAt?: Date;

  // Only getters and setters — no behavior, no enforcement
  setStatus(s: string) { this.status = s; }
  setApprovedBy(userId: string) { this.approvedBy = userId; }
}

// All logic in the service
class OrderService {
  async confirmOrder(orderId: string, userId: string) {
    const order = await this.repo.findById(orderId);
    if (order.status === 'pending') {
      order.setStatus('confirmed');
      order.setApprovedBy(userId);
      order.approvedAt = new Date();
      await this.repo.save(order);
    }
  }
}

The service knows the rules. The order object enforces nothing. Any code anywhere can call order.setStatus('confirmed') directly, bypassing every check in OrderService. The invariant “only pending orders can be confirmed” exists only inside one method of one service class.

Fowler’s verdict: this is an anti-pattern because it defeats the purpose of object-oriented design. An object that owns no behavior and enforces no invariants is not a domain model — it is a glorified C struct with Java syntax.

Why it becomes a problem — with complex invariants

The anemic model’s rot appears gradually, and it appears specifically when invariants become complex or multiple.

In the B2B order platform, the rules governing an order’s lifecycle multiplied over time:

  • A pending order can be confirmed only if it has at least one line item.
  • An order above $10,000 requires approval before confirmation.
  • A confirmed order can be cancelled only within 24 hours.
  • A cancelled order cannot be invoiced, confirmed, or re-opened.
  • An order’s total amount must equal the sum of its line items at all times.

Each of these rules was implemented inside a different service method. confirmOrder checked two of them. cancelOrder checked one. The billing job checked none — it had been written by a different team that did not know the order lifecycle rules. The bug was not a programming mistake — it was a structural mistake. The rules were scattered across service methods that had no guarantee of being the only code path that could mutate the order.

Encapsulation is the core property the anemic model destroys. Encapsulation is not about hiding fields behind getter/setter method calls — that is just syntactic wrapping. Real encapsulation means that an object’s internal state can only be changed through operations that enforce the object’s invariants. If any external code can call setStatus('confirmed') directly, the Order object provides no encapsulation regardless of whether status is private.

The rich domain model alternative

A rich domain model places behavior with the data it governs. The Order object owns its state transitions and enforces its invariants at the boundary of each operation:

// Rich: Order owns its invariants
class Order {
  private status: OrderStatus;
  private lineItems: LineItem[];
  private totalAmount: Money;
  private approvalRecord?: ApprovalRecord;

  confirm(approvedBy: UserId): void {
    if (this.status !== OrderStatus.Pending) {
      throw new InvalidOrderTransitionError(
        `Cannot confirm an order in status ${this.status}`
      );
    }
    if (this.lineItems.length === 0) {
      throw new OrderInvariantViolation('Cannot confirm an order with no line items');
    }
    if (this.totalAmount.greaterThan(Money.of(10_000, 'USD')) && !this.approvalRecord) {
      throw new OrderInvariantViolation('Orders above $10,000 require prior approval');
    }
    this.status = OrderStatus.Confirmed;
    this.confirmedAt = new Date();
  }

  cancel(): void {
    if (this.status === OrderStatus.Cancelled) {
      throw new InvalidOrderTransitionError('Order already cancelled');
    }
    this.status = OrderStatus.Cancelled;
    this.cancelledAt = new Date();
  }
}

Now the BillingJob cannot set status = 'confirmed' directly — it must call order.confirm(), which enforces every relevant rule. The billing bug from the opening story becomes impossible: calling confirm() on a cancelled order throws an error. No second caller can bypass it.

Why this works

Why does co-locating behavior with data matter beyond style preference? Because the domain object becomes the single point of truth for what valid state looks like. With an anemic model, the truth is distributed across every service method that ever touches the object — and across every developer who might someday write a new service method. With a rich model, the truth is in one place. You can read the Order class and know every state transition rule without reading the rest of the codebase. That property matters most when the domain is complex and the team is large.

The honest counter-case: when anemic is fine

Fowler’s critique is correct for complex domains with meaningful invariants. It is NOT a universal law. The anemic model is perfectly appropriate when:

The domain is genuinely CRUD. A user profile management service — create, read, update, delete user records — has no state machine, no multi-step lifecycle, no invariants beyond “email must be unique.” A rich domain model here adds indirection and boilerplate with no return. An anemic model is honest about what the domain is.

The domain is simple and stable. Configuration tables, lookup tables, reference data — this is data management, not domain modeling. ORM entities with getters and setters are the right fit.

The application is short-lived or throwaway. A data migration script, a one-time ETL job, an internal admin panel used by three people — the cost of establishing a rich domain model is not justified by the return.

The anti-pattern label applies when a team uses an anemic model for a complex domain with meaningful lifecycle invariants — and then experiences the consequences (invariant violations, scattered logic, inability to reason about state) while insisting it was the right choice. The problem is not anemic-vs-rich as a religious question. The problem is using the wrong tool for the domain’s actual complexity.

lesson.inset.note

The Transaction Script pattern (also Fowler’s term) is the anemic approach taken to its logical conclusion: a procedure that runs through a series of steps to complete a business transaction, with no persistent domain objects at all. Transaction Script works well for simple workflows where the procedural clarity outweighs the loss of encapsulation. It becomes a problem when the procedures grow long, overlap in their state mutations, and start duplicating the same business rules in multiple places. The procedural approach scales with simplicity; the rich domain model scales with invariant complexity.

Quiz

The B2B order platform has an invariant: a confirmed order can only be cancelled within 24 hours of confirmation. This rule is checked in `OrderService.cancelOrder()`. A new background job reads orders and calls `order.setStatus('cancelled')` directly to clean up stale orders. What structural property of the anemic model allowed this bug to appear?

Quiz

A team is building a B2B order platform billing module. The billing module creates invoices, stores payment records, and links them to orders. There are no complex state transitions — an invoice is created once, marked paid once, and never changes state again. Should the team use a rich domain model or an anemic model for Invoice?

Quiz

Refactoring an anemic Order to a rich domain model, the team discovers they cannot simply add methods like `confirm()` to the Order class — the existing code calls `order.setStatus()` in 47 different places. What does this distribution tell you, and what does it mean for the refactoring?

Recall before you leave
  1. 01
    What makes an anemic domain model an anti-pattern, and what property of the domain determines whether it is actually a problem?
  2. 02
    What does real encapsulation mean in the context of a domain object, and how does a rich domain model provide it?
  3. 03
    When is the anemic domain model an appropriate choice, and how do you tell when a domain has outgrown it?
Recap

The anemic domain model is the natural result of building a layered architecture and then hollowing out the domain layer. Domain objects become data bags — fields, getters, setters — and services carry all the logic. It looks like object-oriented design because it uses classes, but it provides none of the encapsulation that makes OO useful.

Fowler’s critique: the anemic model is an anti-pattern for complex domains because it separates behavior from the data the behavior is supposed to protect. Any caller can bypass the service that contains the rules and mutate domain object state directly. Invariants drift into implicit conventions, maintained by code review and team discipline rather than structural enforcement. When the domain grows in complexity and the number of callers grows, those conventions break.

The rich domain model puts behavior with data. Order.confirm() carries the full invariant logic for a confirmation transition. No caller can reach around it. The domain object’s API is the set of valid operations, and each operation enforces its preconditions.

The honest counter-case: for genuinely simple CRUD domains — two-state entities, reference data, short-lived scripts — the anemic model is pragmatic and appropriate. The boilerplate and indirection of a rich model add cost without buying anything. The decision should be made deliberately, based on the domain’s actual invariant complexity, not as a reflexive pattern choice.

The next lesson examines what happens when layering is applied but the discipline does not hold: the transaction script trap, layer-skipping, and the fat service that becomes the god object of the application.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.