The Anemic Domain Model
Data objects with no behaviour, and all the logic in services. Widely called an anti-pattern, widely defended, and correct more often than either side admits.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
My entities are data holders and the rules live in services. Is that actually a problem, or only a problem in some systems?
A reviewer blocks a pull request: "these are anemic — the rules should be on the objects." The system is an internal admin tool with forty tables, mostly CRUD, and three rules worth the name.
The reviewer is right by default: behaviour belongs with data, so move every rule onto the entity and the model becomes rich. This is what the books say and it is not usually questioned.
For an object with no rules, "putting behaviour on it" means inventing behaviour. The result is a class with getters, setters and a method that returns a field, and it is worse than the plain struct because it implies rules that do not exist.
- For an object with no rules, "putting behaviour on it" means inventing behaviour. The result is a class with getters, setters and a method that returns a field, and it is worse than the plain struct because it implies rules that do not exist.
- It breaks the other way too, and this is the part the defenders of anemia understate: in a system that does have rules, scattering them across services means each new write path is a fresh opportunity to skip one, and the rules drift apart silently (Invariant Leaks).
- With generated data classes, hand-written behaviour on them is lost at the next regeneration — so "rich model" in this codebase means abandoning the generator, which is a much larger decision than the review comment implied.
- The argument as usually conducted has no falsifiable content. "Anemic is an anti-pattern" and "anemic is fine" are both unfalsifiable until someone names a rule and asks where it is enforced.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The team is four engineers, two of whom joined this quarter, and the codebase must stay legible to them.
- A code generator produces the data classes from the database schema, so behaviour added to them is overwritten on regeneration.
- The serialization framework requires no-argument constructors and public setters on anything crossing the wire.
- Wherever a business rule exists, there is exactly one place it is enforced, whichever style is chosen.
- A rule cannot be bypassed by any supported way of creating or modifying the data.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Whichever style is chosen, some named place owns each rule, and a reader can find it from the rule's name.
- In an anemic design, the service owns the rule and the data object owns nothing — which is honest, provided the service is the only write path.
- In a rich design, the object owns the rule and every write path goes through it — which is only true if the object is not also constructed by a deserializer or a generator (The Aggregate Root).
- The reviewer owns naming which rule they are worried about. A review comment about style with no rule attached cannot be acted on.
- The boundary that matters is around the rule, not around the object. Both styles can put a rule in exactly one place, and both can scatter it.
- A generated data class is outside the model boundary by construction; treating it as the domain model is the actual design error in this codebase, whichever style wins.
- The wire format is its own boundary, and its requirements — public setters, default constructors — should stop at a DTO rather than shaping the model (Three Models, Not One).
The same rule, two homes
Read both versions as someone adding a seventh way to modify an order next quarter. In the first, they must know the rule exists and remember to call the service. In the second, they cannot avoid it.
That is the entire argument, and it is worth noticing what it is *not* about: neither version is cleaner, shorter or more object-oriented in any way that matters. The difference is how many places can violate the rule.
class Order {
id!: string
status!: string
total!: number
creditLimit!: number
}
class OrderService {
confirm(order: Order) {
if (order.total > order.creditLimit) throw new OverLimit()
order.status = 'confirmed'
this.repo.save(order)
}
}
// the bulk importer, written later by another team:
order.status = 'confirmed'
repo.save(order) // compiles, passes review, skips the ruleclass Order {
private status: OrderStatus = 'draft'
confirm() {
if (this.total().greaterThan(this.creditLimit)) throw new OverLimit(this.id)
this.status = 'confirmed'
}
}
// the bulk importer has no other option:
order.confirm() // there is no setter to reach for
repo.save(order)Not because behaviour belongs with data as a principle, but because in the second version the number of places that can produce a confirmed over-limit order is one, and in the first it is "however many people write code against this class". That difference is worth nothing when there are two write paths and a great deal when there are twenty. If this system genuinely has one write path and no other rules, the first version is the better design and the reviewer is wrong.
Where the crossover actually is
The honest way to have this argument is to stop arguing about the model and price a change. Below is the same requirement — a new rule — under both designs, in a system with three rules and one with thirty.
The numbers are illustrative of shape rather than measured, but the shape is the point: anemia is flat and cheap at low rule counts and grows with the number of write paths, while a rich model has a fixed setup cost and then stays flat.
A seventh business rule is added, affecting order confirmation, and it must apply to every path that can confirm an order — API, admin UI, bulk import, retry job, CSV tool, partner webhook.
Six edits and a discovery phase, because nothing in the code says which paths can confirm an order. The two paths added most recently already disagree about an earlier rule, which is how this design fails: silently and gradually.
One method. Every path already calls it because there is no setter, so no path can be missed and no discovery is needed.
Deciding for your system, not in general
The scoring below is deliberately for two different systems rather than for two designs, because the design question is meaningless without the system. The admin tool in the requirement at the top of this lesson is the first column; a claims-processing engine is the second.
If you take one thing from this lesson: the question "is our model anemic?" is not answerable and not useful. The question "how many code paths can violate rule X?" is both.
| Option | Simplicity | Flexibility | Testability | Migration cost | Operational | Note |
|---|---|---|---|---|---|---|
| Anemic — 40 tables, 3 rules, admin tool | Plain data, functions, a generator doing the boring work. Two new engineers are productive in a week. The three rules each live in one function and that is genuinely enough. | |||||
| Rich — 40 tables, 3 rules, admin tool | Mapping layer, hand-written constructors, generator abandoned or worked around. Everything above still works, and now costs more. This is over-design (Over-Design and Under-Design). | |||||
| Anemic — claims engine, 200 interlocking rules | Rules spread across forty services with overlapping conditions. Correct today, and every quarter one of them is skipped by a new path. This is the case where "anti-pattern" is fair. | |||||
| Rich — claims engine, 200 interlocking rules | Rules on the objects they constrain, unskippable, testable without infrastructure. The mapping cost is unchanged from the row above it, and here it buys something. |
caveat The rows are the same two designs and their scores invert entirely between systems, which is the finding — no score here is a property of the design. What the table cannot express at all is the transition: a system that starts in row one and grows into row three does not get to re-choose cheaply, and most real codebases are somewhere mid-conversion, which is the state with the costs of both. That risk, rather than either endpoint, is the strongest practical argument for thinking about it early.
How to build it
Most important first.
- Ask the falsifiable question: name a rule, and ask how many places can violate it. If the answer is one, the model is fine whatever it is called.
- Where there are few rules, keep the data plain and the operations as functions. This is a legitimate design, not a failure to apply a pattern (Transaction Script).
- Where rules are many and interlocking, move them onto the objects they constrain — not for purity, but because a rule enforced in the constructor cannot be skipped by a caller who did not read the service (Enforcing Invariants).
- Do not mix the two accidentally. A codebase where half the rules are on objects and half in services is the worst of both, because a reader has to check both places for every rule.
- If generated classes are in the way, put the model beside them and map — the generator keeps its job, the rules get a home, and the mapping is boring and testable (Boundary Adapters).
- Judge by change cost, not by vocabulary. Price the next likely rule change under both shapes and let the number decide (The Cost of Change).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Anemic, few rules: adding a field costs one class and one mapper, and adding a rule costs one function. Genuinely cheap, and it stays cheap while the rule count is low.
- Anemic, many rules: adding a rule costs an audit of every write path, and the audit has no terminating condition. This is the cost that grows superlinearly and is the real argument against anemia.
- Rich, few rules: adding a field costs a class, a mapper and a constructor argument, and the object gains a method that does nothing interesting. Slightly more expensive with no return.
- Rich, many rules: adding a rule costs one method on one object, and every existing path inherits it. This is the crossover the whole argument is about, and it happens somewhere between three and roughly a dozen interacting rules — not at a fixed number.
- Rich models cost constructor discipline, mapping code and a running fight with frameworks that want default constructors and setters.
- Anemic models cost vigilance: they are correct exactly as long as every write path goes through the intended service, and nothing structural enforces that.
- Converting from one to the other mid-life is expensive and rarely completed, which leaves the hybrid state that is worse than either — so the choice deserves more thought than a review comment.
What can go wrong
- Anemic: the same rule appears in three services with three slightly different conditions, and nobody can tell which is authoritative (Duplicate Knowledge).
- Anemic: an object is constructible in an invalid state, and the validity check lives in a service that some caller did not use.
- Rich: the entity accumulates behaviour from every module that touches it and becomes the largest and most-contended file in the repository (God Object).
- Rich: the object cannot be deserialized without bypassing its constructor, so the invariants it claims to protect do not hold for anything that came from outside the process — the mitigation defeated silently.
- An anemic model has data objects depending on nothing — genuinely the lowest-coupling arrangement available, and the strongest technical point in its favour.
- The services depend on the data objects and on each other, and that second dependency is where anemia usually goes wrong: rules calling rules with no boundary between them (Dependency Cycles).
- A rich model inverts it: behaviour depends on data it owns, and services depend on the model. Fewer edges, and the edges point inward.
- "Anemic is an anti-pattern." It is an anti-pattern *for domains with substantial interlocking rules*, where it reliably produces scattered enforcement. For CRUD, reporting, admin tools and data pipelines it is frequently the better design, and calling it an anti-pattern there is cargo cult (Transaction Script).
- "Getters and setters make a model anemic." The symptom is where the *rules* live, not whether fields are accessible. A rich model with accessors is fine; an anemic model with private fields and a builder is still anemic.
- "Rich models are object-oriented and anemic models are procedural." Functional codebases separate data from behaviour by design and are not anemic, because the rules still live in one place with the type they constrain. The distinction is enforcement locality, not paradigm (Composition Over Inheritance is a different axis entirely).
- "We should convert incrementally." Half-converted is the worst state, because every rule now has two plausible homes. If you convert, convert one bounded area completely (Incremental Migration).
- duplicate-knowledge
- feature-envy
Testing it, and how it ages
- The decisive test is not a unit test — it is asking, for one named rule, how many code paths could violate it. Both designs pass or fail this on their own merits.
- In an anemic design, test each service and add a test that no other write path exists — usually a lint or architecture test that only the service module writes to the repository (Internal Module Contracts).
- In a rich design, test that the object cannot be constructed invalid, and separately that the deserializer goes through the same path (Contract Tests).
- In both, a property test over a sequence of operations asserting the rule still holds is worth more than any number of example tests (Property-Based Testing).
- Systems drift from anemic toward rich as rules accumulate, and the drift is usually late — the pain arrives some time after the point where the change would have been cheap.
- The reverse drift also happens and is under-discussed: a rich model in a system whose rules moved into a workflow engine or a rules service becomes ceremony around data, and the honest move is to simplify it back.
- The forcing function in either direction is a rule that got violated in production by a path nobody remembered. That event, not a review comment, is what should trigger the change (Revisit Triggers).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- CONTESTEDThe strongest form of the pro-anemic case, stated properly: separating data from behaviour keeps data objects dependency-free and serializable, matches what ORMs, code generators and wire formats actually want, and lets rules be composed, tested and reused as functions without dragging an object graph along. Its advocates observe that "rich" models in practice become god objects, and that functional codebases achieve correctness with exactly this separation. The strongest form of the anti-anemic case: enforcement locality is the only thing that stops a rule being skipped, and a rule that lives in a service is enforced only for callers who chose to use that service — which over years is not all of them. Both are right about different systems, and the discriminator is the number of interlocking rules and the number of write paths, not taste.
- DOMAIN-SPECIFICIn insurance, payroll, trading and logistics the rules interlock heavily and anemia has a measurable cost in scattered enforcement. In admin tools, CMSs, reporting and ETL there are almost no invariants to protect, and a rich model is ceremony that new engineers must learn before they can add a column.
- FRAMEWORK-SPECIFICFrameworks that generate entities from a schema, require no-arg constructors, or bind directly from request bodies push hard toward anemia — not as a philosophy but as the path of least resistance. On such stacks a rich model requires a separate mapped model, which is real cost that the same design does not carry on a stack with hand-written persistence.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — languages with no way to make construction private, or whose serialization bypasses constructors, weaken the enforcement argument considerably, which is why this debate looks different in Python than in Rust.