Anemic Domain Model — Specification¶
Reference: Martin Fowler, AnemicDomainModel (https://martinfowler.com/bliki/AnemicDomainModel.html), 2003.
This file gives you precise, falsifiable criteria for calling a class anemic. Vague accusations ("this feels like an anemic model") lose code reviews. Numbers and ArchUnit rules win them.
1. Formal definition¶
A class C is anemic when all of the following hold:
Cdeclares one or more domain-meaningful fields (not just an ID or timestamps).Cexposes at least one mutator (setter, or constructor + setter combination) that lets an arbitrary caller placeCinto a state forbidden by the business rules.- Behavior that mutates
Cor computes values derived fromC's state lives in a separate "service" class rather than onCitself. - Removing
C's behavior-bearing methods leaves the class still useful to its current callers — meaning callers depend onConly as a data carrier.
A rich model is the negation: invariants hold by construction, mutations are domain operations, and removing behavior from the class breaks callers.
2. Quantitative metrics¶
2.1 Method-to-field ratio (MFR)¶
Exclude getX, setX, equals, hashCode, toString, JPA lifecycle callbacks, and synthetic methods.
| MFR | Verdict |
|---|---|
| 0.0 | Pure data class — definitely anemic |
| 0.0 – 0.3 | Suspicious, almost certainly anemic |
| 0.3 – 0.8 | Borderline, inspect manually |
| 0.8 – 2.0 | Likely rich |
| > 2.0 | Probably has behavior that belongs elsewhere |
2.2 Getter-to-behavior ratio (GBR)¶
Behavior methods are the ones that do something other than return a field. GBR > 3 strongly suggests anemia. GBR < 1 suggests over-encapsulation worth checking.
2.3 LCOM (Lack of Cohesion of Methods)¶
LCOM4 counts disjoint sets of methods that share fields. For an anemic class with N getters and M setters:
because every getter touches one field and never overlaps with others. A rich class with cross-field invariants has LCOM4 = 1.
2.4 Mutator-to-invariant ratio¶
Count setX methods on the class. Count business invariants the class is supposed to maintain (you'll find these in the requirements, the database constraints, or scattered across services). If setters > invariants you have an anemia signal — the class lets you bypass at least one invariant per setter that doesn't validate.
3. Rich vs anemic — worked comparison¶
Anemic¶
public class Account {
private UUID id;
private BigDecimal balance;
private String currency;
private boolean frozen;
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public BigDecimal getBalance() { return balance; }
public void setBalance(BigDecimal balance) { this.balance = balance; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public boolean isFrozen() { return frozen; }
public void setFrozen(boolean frozen) { this.frozen = frozen; }
}
- Domain fields: 4 (id, balance, currency, frozen).
- Behavior methods: 0.
- MFR = 0.0 / 4 = 0.0 → anemic.
- LCOM4 = 4 (every setter is a disjoint cluster).
- Invariants violated freely: negative balance, currency change mid-flight, balance change on frozen account.
Rich¶
public final class Account {
private final AccountId id;
private Money balance;
private boolean frozen;
private Account(AccountId id, Money initial) {
this.id = Objects.requireNonNull(id);
this.balance = Objects.requireNonNull(initial);
this.frozen = false;
}
public static Account open(AccountId id, Money initialDeposit) {
if (initialDeposit.amount().signum() < 0) {
throw new IllegalArgumentException("Negative initial deposit");
}
return new Account(id, initialDeposit);
}
public void deposit(Money amount) {
requireActive();
requireSameCurrency(amount);
balance = balance.add(amount);
}
public void withdraw(Money amount) {
requireActive();
requireSameCurrency(amount);
if (balance.amount().compareTo(amount.amount()) < 0) {
throw new InsufficientFundsException(id, balance, amount);
}
balance = balance.subtract(amount);
}
public void freeze() {
if (frozen) throw new IllegalStateException("Already frozen");
frozen = true;
}
private void requireActive() {
if (frozen) throw new AccountFrozenException(id);
}
private void requireSameCurrency(Money m) {
if (!balance.currency().equals(m.currency())) {
throw new CurrencyMismatchException(balance.currency(), m.currency());
}
}
public AccountId id() { return id; }
public Money balance() { return balance; }
public boolean isFrozen() { return frozen; }
}
- Domain fields: 3 (id, balance, frozen).
currencymoved intoMoney. - Behavior methods: 4 (
open,deposit,withdraw,freeze). - MFR = 4 / 3 ≈ 1.33 → rich.
- LCOM4 = 1 (every behavior touches
balanceandfrozentogether). - Invariants enforced: non-negative balance, currency consistency, no operations on frozen accounts.
4. Detection automation¶
4.1 ArchUnit rules¶
@AnalyzeClasses(packages = "com.example.domain")
class AnemiaDetectionTest {
@ArchTest
static final ArchRule no_setters_on_domain_entities =
methods()
.that().arePublic()
.and().haveNameStartingWith("set")
.and().areDeclaredInClassesThat().areAnnotatedWith(Entity.class)
.should().bePrivate()
.orShould().beProtected();
@ArchTest
static final ArchRule entities_must_have_behavior_methods =
classes()
.that().areAnnotatedWith(Entity.class)
.and().areNotInterfaces()
.should(new ArchCondition<JavaClass>("declare at least one non-accessor public method") {
@Override
public void check(JavaClass clazz, ConditionEvents events) {
long behaviorCount = clazz.getMethods().stream()
.filter(m -> m.getModifiers().contains(JavaModifier.PUBLIC))
.filter(m -> !m.getName().startsWith("get"))
.filter(m -> !m.getName().startsWith("set"))
.filter(m -> !m.getName().startsWith("is"))
.filter(m -> !m.getName().equals("equals"))
.filter(m -> !m.getName().equals("hashCode"))
.filter(m -> !m.getName().equals("toString"))
.count();
if (behaviorCount == 0) {
events.add(SimpleConditionEvent.violated(clazz,
clazz.getName() + " is anemic: no behavior methods"));
}
}
});
@ArchTest
static final ArchRule services_must_not_be_the_only_mutators =
noClasses()
.that().haveSimpleNameEndingWith("Service")
.should().callMethodWhere(JavaCall.Predicates.target(
HasName.Predicates.nameMatching("set[A-Z].*"))
.and(JavaCall.Predicates.target(
HasOwner.Predicates.With.owner(
JavaClass.Predicates.resideInAPackage("..domain..")))));
}
4.2 Static analysis hints¶
- PMD:
DataClassrule flags pure data carriers. - SonarQube:
java:S1820(too many fields) andjava:S1448(too many methods, often appears with anemic + helper services). - Custom Checkstyle: write a check that fails when an
@Entityclass has onlypublicgetters/setters and no other public methods.
5. Decision flow¶
When reviewing a class, walk this in order:
- Is the class in the domain layer? If no → skip; anemic data carriers are fine in DTO/persistence/projection layers.
- Does it have business invariants documented? If no → write them first, then re-evaluate.
- Compute MFR. If MFR < 0.3 → anemic candidate.
- List its setters. For each setter, ask "can a caller create an invalid state through this?" If yes for any → anemic.
- Look at the services calling this class. Are they implementing logic that mutates the class's state based on the class's state? That logic belongs on the class.
- Apply the fix: replace setters with behavior methods, move invariant checks into the class, validate in the constructor.
6. When anemic is the correct answer¶
- DTOs at API boundaries — they carry data across the wire and have no business rules.
- Read models / projections in CQRS — they exist to be displayed, not validated.
- Event payloads — events are immutable facts, often records with no behavior beyond what
recordgives you. - Configuration objects —
@ConfigurationPropertiesPOJOs. - JPA entities for legacy schemas when you cannot refactor and choose to keep a thin entity + a separate domain object that wraps it.
Outside these contexts, anemia in the domain layer is a defect.
7. Mapping the claims to canonical sources¶
Every assertion in this topic traces back to named literature. Use these when defending a design in review — citing the source ends the argument faster than re-deriving it.
| Claim in this topic | Canonical source |
|---|---|
| "Anemic Domain Model is an anti-pattern; OO means combining data and process" | Fowler, AnemicDomainModel (martinfowler.com/bliki, 2003) |
| "The Service layer that holds the logic is just procedural" | Fowler, Patterns of Enterprise Application Architecture (PoEAA, 2002) — Domain Model pattern (pp. 116–124) vs. Transaction Script (pp. 110–115); anemic = Transaction Script wearing a Domain Model's class names |
| "Entities own identity + behaviour; value objects own validity + immutability" | Evans, Domain-Driven Design (2003) — ch. 5, Entities and Value Objects; ch. 6, Aggregates (invariant boundaries), Factories, Repositories |
| "Domain Services hold only logic belonging to no single entity" | Evans, DDD (2003) — ch. 5, Services; Vernon, Implementing DDD (2013) — ch. 7 |
| "Tell, Don't Ask: don't pull state out to decide externally" | Hunt & Thomas, The Pragmatic Programmer (1999) — Tell, Don't Ask; phrasing originates with Sharp, Smalltalk by Example (1997) |
| "Bundle data with the behaviour that uses it; minimise mutability" | Bloch, Effective Java 3e — Item 15 (minimise accessibility), Item 17 (minimise mutability); Meyer, Object-Oriented Software Construction 2e — information hiding, design by contract |
| "Anemia is the absence of cohesion between fields and methods" | Chidamber & Kemerer (1994) — LCOM metric; Fowler, Refactoring 2e — Move Method, Replace Data Value with Object, Encapsulate Field |
The crisp historical framing: Fowler's PoEAA names two ways to organise domain logic — Transaction Script (procedure per use case, operating on dumb data) and Domain Model (behaviour on the objects). Both are legitimate; the Transaction Script is honest for simple CRUD. The Anemic Domain Model is the failure to choose: it pays the Domain Model's modelling cost (an object graph, ORM mapping) while keeping the Transaction Script's procedural structure — the worst of both.
8. Reading list¶
- Martin Fowler — AnemicDomainModel (martinfowler.com/bliki/AnemicDomainModel.html, 2003). The naming essay. Short; read it whole.
- Martin Fowler — Patterns of Enterprise Application Architecture (2002). Domain Model (pp. 116–124) and Transaction Script (pp. 110–115) — the two honest alternatives the anemic model fails to pick between. Also Service Layer (pp. 133–141).
- Eric Evans — Domain-Driven Design (2003). Ch. 5 (Entities, Value Objects, Services), Ch. 6 (Aggregates, Factories, Repositories). The canonical treatment of where behaviour and invariants belong.
- Vaughn Vernon — Implementing Domain-Driven Design (2013). Ch. 5–10. The practical "how", with the anti-corruption between anemic boundary shapes and rich aggregates spelled out.
- Andrew Hunt & David Thomas — The Pragmatic Programmer (1999). Tell, Don't Ask — the lens that turns "this is anemic" into a concrete refactoring.
- Martin Fowler — Refactoring (2nd ed., 2018). Move Function (Move Method), Encapsulate Field, Replace Primitive with Object, Combine Functions into Class — the mechanical moves that enrich an anemic model.
- Joshua Bloch — Effective Java (3rd ed.). Item 15 (minimise accessibility), Item 16 (accessor methods over public fields), Item 17 (minimise mutability).
- Bertrand Meyer — Object-Oriented Software Construction (2nd ed.). Information hiding and Design by Contract — the theory under "the object owns its invariants".
- Chidamber & Kemerer (1994), A Metrics Suite for Object-Oriented Design. The LCOM metric used in §2 to quantify anemia.
Memorize this¶
- Anemic = data + no invariants + behavior elsewhere. All three conditions must hold.
- MFR < 0.3 in the domain layer is a red flag. Investigate every such class.
- LCOM4 ≈ field-count for anemic classes, ≈ 1 for rich classes. Cohesion follows behavior.
- Setters per invariant > 1 means at least one invariant is unguarded. Count them in code review.
- ArchUnit rules catch anemia in CI. Add them once and stop arguing forever.
- DTOs, read models, and events are correctly anemic. Domain entities are not.
In this topic