The Anemic Domain Model Trap
Where Should Business Rules Live?
Last week we pulled Order out from under ORM. Framework imports gone. Repository behind a port. The domain compiles without a database, runs without a database, can be tested without a database.
The Order class itself had two methods on it: cancel() and isShippable(). We didn’t think about why. They were just there, with the data.
This post is about that choice. The one most production codebases get wrong.
The Domain Is Still Hollow
Picture the version we didn’t see last week. Same package layout. Same hexagonal split. Order is a pure POJO, no framework imports, fully decoupled from persistence. It looks like this:
public class Order {
private OrderId id;
private OrderStatus status;
private LocalDate placedOn;
private LocalDate shippedOn;
// getters and setters skipped
}
public class OrderService {
public boolean isShippable(Order order) {
return order.getStatus() == OrderStatus.PAID
&& order.getShippedOn() == null;
}
public void cancel(Order order) {
if (order.getStatus() == OrderStatus.SHIPPED) {
throw new IllegalStateException(
"Cannot cancel a shipped order");
}
order.setStatus(OrderStatus.CANCELLED);
}
}The hexagonal diagram still checks out. The framework is still out of the domain. And the rules of what an order is live in OrderService. The Order class itself is a row pretending to be a model, except now with no schema attached.
Hexagonal architecture didn’t promise that we’d have a domain. It promised that our domain wouldn’t be tied to infrastructure1. We can satisfy the second promise and still fail the first.
What the Decoupling Didn’t Fix
Last week’s split solved a coupling problem. This post is about a modeling problem.
When we moved JPA imports out of Order, we earned the right to ask a different question: “what should this class do?” That question is often skipped in most codebases because the ORM version feels like an answer. “It’s an entity, it maps to a table, it has its fields.” Once the framework defines the shape, behavior becomes the service’s job by default. The framework gets out of the way. The habit stays.
Pure POJO is necessary, but it’s not sufficient.
Fowler Named It in 2003
Martin Fowler wrote it down in AnemicDomainModel (2003). Same year as Eric Evans’ Domain-Driven Design. Two halves of one argument.
Fowler’s diagnosis was structural. An anemic domain model has data, getters, setters, and external rules. The class compiles cleanly. It passes type-checking. It has nothing to do with the business it claims to model.
Evans’ prescription was the other half. Behavior belongs to the entity that owns the data. Not “the service that operates on the entity.” Not “the orchestrator that calls the entity’s setters.” The entity itself.
Both writers were arguing from the same starting point: the central promise of object-oriented design.
Object-oriented design’s promise: data and operations bundled together. Encapsulation.
An anemic model breaks the promise. Data is inside the class, operations are outside. The class declares its state, but cannot enforce which state changes are valid. The invariant (”an order cannot be canceled after shipping”) lives in a service that any caller can bypass. The type owns the data and not the rules.
That’s procedural code in OO syntax.
Move the Verbs Home
We already wrote the right version last week, without explaining why. Let’s clear the air now:
public class Order {
private final OrderId id;
private OrderStatus status;
private final LocalDate placedOn;
private LocalDate shippedOn;
public Order(OrderId id, LocalDate placedOn) {
this.id = id;
this.status = OrderStatus.PENDING;
this.placedOn = placedOn;
}
public boolean isShippable() {
return status == OrderStatus.PAID
&& shippedOn == null;
}
public void cancel() {
if (status == OrderStatus.SHIPPED) {
throw new IllegalStateException(
"Cannot cancel a shipped order");
}
status = OrderStatus.CANCELLED;
}
// boilerplate skipped
}The setters are gone. The state changes are part of business operations. cancel() is the only way to move an Order to CANCELLED, and cancel() enforces the rule for every caller. Bypassing the invariant would mean refusing to call the method that exists for the purpose.
The rest of the system shrinks. There’s no OrderService.cancel(order) for anyone to call. The invariant has one home, and the home is the class that owns the state2.
A second example: banking.
public class Account {
private final AccountId id;
private Money balance;
public void withdraw(Money amount) {
if (balance.isLessThan(amount)) {
throw new InsufficientFundsException();
}
balance = balance.minus(amount);
}
public void deposit(Money amount) {
balance = balance.plus(amount);
}
// boilerplate skipped
}One invariant: the balance never goes below zero. The withdraw method is the gate. There is no path to a negative balance because there is no method that does it.
The anemic alternative is Account with a getBalance() and setBalance(), and an AccountService.transfer(from, to, amount) that reads, checks, and sets. The check is correct today. It’s also bypassable by anyone who calls setBalance() directly. The invariant lives by convention3.
Cross-account behavior (transfer between two accounts) doesn’t fit in either account alone. It belongs in a domain service that delegates to each account’s own methods4. There’s a deeper layering question hiding here (where exactly application logic should live) that’s worth its own post5. The rule of thumb: rules belong on the entity that owns the state.
When Anemia Is the Right Call
Not every object should have behavior. Calling a behaviorless object a domain model is also problematic.
DTOs, request and response payloads, read-side projections, CQRS query records, and the ORM row classes we wrote last week. These are anemic on purpose. Putting cancel() on OrderRow would be a mistake.
The reason: DRY isn’t about code, it’s about knowledge (Hunt and Thomas, The Pragmatic Programmer). There are two kinds of duplication worth pulling apart.
Code duplication is the same shape in two places. Order and OrderRow carry the same fields today. Visible in the diff.
Knowledge duplication is having the same business rule in two places (even if the two pieces of code are not identical). “Cannot cancel after shipping” living in both Order and a service is the real DRY violation, even when the code on each side looks different.
Order and OrderRow look like a candidate for merging. They share fields, after all. But they have different reasons to change. Order changes when business rules change. OrderRow changes when the storage shape changes: a denormalized column for query performance, a renamed field after a migration. Different forces, change triggers, and cadence. Code duplication with a purpose.
Sandi Metz’s correction to misread-DRY applies: duplication is far cheaper than the wrong abstraction6. The wrong abstraction here would be one class trying to serve both the business rules and the persistence shape. We’ve seen that class. It was last week’s JPA-annotated Order that taught us the database was driving.
Code duplication you can see. Knowledge duplication you can only feel, when both copies need to change at once.
Of course, there is a price: mapping between different representations. But that is far cheaper than a god class getting out of hand.
The Takeaway
If the verbs only live in a Service class operating on a dumb model, the model isn’t a model. It’s a record with extra steps.
Pure POJOs don’t make the domain. Encapsulation does. The class that owns the data should also own the rules that govern it.
TLDR
An anemic domain model has objects that hold data but contain no business logic. All the behavior lives in service classes.
This isn’t just a naming issue. It causes logic duplication, testing difficulty, and makes it impossible to reason about business rules by reading the domain.
The fix isn’t “move everything into entities overnight.” It’s identifying which behavior belongs to which class and migrating one method at a time.
A domain object that validates its own invariants is the simplest, highest-value starting point.
Try This Week
Open your most important entity class. If it’s just getters and setters, you have an anemic domain model. Find one validation rule that currently lives in a service class and ask: should the entity enforce this itself?
Media attributions:
Cover image by the author (generated with Gemini)
The previous post used the database as the canonical example of infrastructure coupling because the database is the worst case: invasive and expensive to change. “Invasive” means it couples through the type system (imports, annotations, generics). Logging frameworks, ORMs, and validation libraries all qualify. “Expensive to change” means it locks future moves through production state. Database schemas, message queues with live subscribers, and file formats on disk belong to this category. The database sits at the intersection. Logging is invasive but cheap to swap. An async message queue is expensive to change but not invasive. The hexagonal-architecture argument applies to anything with one or both properties.
This requires both aspects of encapsulation: hiding information and bundling data and its operations in the same class.
If you work at a bank where the Account class doesn’t enforce its own invariants like this, please reach out. I’d love to be a client and increase my balance without topping up.
A cleaner DDD treatment of cross-account transfer: model the Transfer itself as a first-class aggregate root with its own lifecycle and invariants, and use event sourcing on the accounts. Balances become derivations over recorded events (AmountWithdrawn, AmountDeposited) rather than a directly mutated state. The invariant is enforced at the transfer boundary, the audit trail is free, and temporal queries (balance as of any timestamp) fall out of the model. Forthcoming: Modeling Transfers: When the Verb Wants to Be a Noun.
Where application logic should live (domain services vs. application services), and how to split behavior when a single aggregate grows heavy (decorator, strategy, composition), are real questions. Each is big enough for its own post. Forthcoming: Where Does Application Logic Go? and When the Aggregate Gets Heavy.
Sandi Metz, The Wrong Abstraction (2016). Her exact phrasing: “duplication is far cheaper than the wrong abstraction.” Her broader point: refactoring duplication into the right abstraction later is easier than refactoring a wrong abstraction back into duplication.

