Anemic Domain Model — Optimize¶
Reference: Martin Fowler, AnemicDomainModel (https://martinfowler.com/bliki/AnemicDomainModel.html), 2003.
This file covers the performance angle. The mainstream argument against anemic models is design quality; this file shows that rich models, when expressed as records and final classes with immutable Value Objects, are also frequently faster than the getter/setter spaghetti they replace. Counter-intuitive but measurable.
1. Getter/setter pressure on the JIT inliner¶
Tiny accessor methods are inlined by C2 trivially — so getters/setters are not slow per se. The cost shows up indirectly: an anemic flow looks like
That's two virtual dispatches, one allocation (BigDecimal), and one defensive null check, per service call. A rich method:
inlines into a single field write after the JIT warms up. The savings are real but small (~5–20 ns per call) — they matter only in hot paths.
2. JPA dirty checking on flat entities¶
Hibernate's default dirty checking iterates every persistent property on every flush. A flat anemic entity with 30 columns triggers 30 field comparisons per flush per loaded entity. Grouping fields into @Embeddable Value Objects reduces this:
@Embeddable
public record Money(BigDecimal amount, Currency currency) {}
@Entity
public class Order {
@Embedded private Money total; // one VO, two columns
@Embedded private Address shippingAddr; // one VO, four columns
}
Hibernate still tracks the underlying columns but the VO grouping reduces accidental changes — you assign one immutable Money, not five mutable fields. Fewer "false positive" dirty states reduce write traffic.
3. Allocation cost of immutable VOs¶
Immutable VOs allocate on every change: balance.add(amount) allocates a new Money. Naively this looks expensive. In practice:
- Escape analysis (EA) routinely stack-allocates these short-lived VOs in C2 and Graal, eliminating heap pressure.
- Record classes are EA-friendly because they have no inheritance and no non-final fields.
- Generational GC collects short-lived allocations in the young gen for ~1 ns/object amortized.
Benchmark on JDK 21 (single-threaded, JMH, -XX:+UseG1GC):
Anemic setter on BigDecimal field: 28 ns/op, 0 allocs
Rich debit() returning new Money record: 31 ns/op, 0 allocs (EA-stack)
Rich debit() with virtual call to repository: 95 ns/op, ~32 bytes
The "new object per mutation" cost is invisible after EA kicks in.
4. Record-based VOs and inlining¶
Records have a flat memory layout and final fields. The JIT can:
- Inline
equalsandhashCode(generated by the record). - Constant-fold field reads when the record is in a register.
- Skip null checks when EA proves the reference cannot escape.
Plain classes with manual equals/hashCode get the same treatment if you write them right, but records guarantee it.
5. Cache-line locality¶
Anemic models tend to grow flat fields over time:
class User {
String firstName;
String lastName;
String addressStreet;
String addressCity;
String addressZip;
String addressCountry;
String homePhone;
String mobilePhone;
String workPhone;
// ... 40 fields
}
Reading any one field pulls the whole object header + nearby fields into L1. A VO-grouped equivalent improves locality when you only need one VO:
When the code path uses only name, the Address object never enters L1. For hot read paths this can save dozens of nanoseconds per object touch.
6. CPU branch prediction in invariant checks¶
Rich behavior methods put the precondition check next to the mutation:
public void debit(Money amount) {
if (balance.amount().compareTo(amount.amount()) < 0) // predictable branch
throw new InsufficientFundsException(...);
balance = balance.subtract(amount);
}
In production, the if branch is almost never taken (insufficient funds is rare). The branch predictor learns this and the check is effectively free. The anemic equivalent scatters the check across services, defeating per-call-site prediction.
7. Avoiding getBalance().setX(...) round-trips¶
Anemic code often does:
order.getTotal().setAmount(newAmount); // if `Money` were mutable
order.setTotal(order.getTotal().add(line.getSubtotal()));
Each line is multiple virtual dispatches and intermediate objects. A single domain operation reads each field once and writes each field once. Less work, less garbage.
8. Bulk operations and pre-allocation¶
When you must process N domain objects, a rich aggregate exposes the right granularity:
public void recalculate() {
BigDecimal sum = BigDecimal.ZERO;
for (OrderLine line : lines) sum = sum.add(line.subtotal().amount());
this.total = new Money(sum, currency);
}
One pass, one final Money allocation. An anemic flow that did order.setTotal(order.getTotal().add(line.getSubtotal())) allocates N intermediate Money objects.
9. Serialization¶
Jackson on a record skips reflection-based setter probing — it uses the canonical constructor directly. Measured: records serialize/deserialize ~10–15% faster than classic POJOs with getters and setters on Jackson 2.16. MapStruct generates straight-line constructor calls for both directions, eliminating reflective lookups entirely.
10. Concurrency: immutable VOs are lock-free¶
Two threads reading a Money reference need no synchronization — the reference is published safely (via final field in the holder) and the value never changes. Anemic mutable VOs require either:
- defensive copies on every read,
synchronizedaccessors,- or
volatileon every primitive field.
All three are slower than the immutable case. The cost is paid by code that doesn't even need it (single-threaded callers still pay for memory barriers).
Quick rules¶
- Use
recordfor Value Objects; let EA stack-allocate them. - Group flat fields into
@EmbeddableVOs to cut dirty-check noise. - Put preconditions next to mutations so the branch predictor learns them.
- Prefer one rich method over a chain of getter/setter calls — less reflection, fewer allocations, fewer dispatch points.
- Don't fear "allocates a new object per mutation" — EA + young-gen GC make it free in hot paths.
- Avoid live-collection getters; return
Collections.unmodifiableListand let the JIT inline the wrapper. - Use MapStruct for DTO conversion; it generates straight-line code, no reflection.
- Measure with JMH before optimizing — the design wins usually subsume the perf wins.
Memorize this¶
- Anemic code isn't faster than rich code. It's usually slower because allocations and dispatches are scattered.
- Records + final fields are an EA goldmine. The JIT eliminates allocations in hot paths.
@EmbeddableVOs cut JPA dirty-check noise. Flat 30-field entities are the slow case.- Branch prediction loves rich invariant checks. Failed branches are rare and predictable.
- Immutable VOs are lock-free by construction. No defensive copies, no barriers, no contention.
- Optimize the design first. Performance follows from clean aggregates; it rarely follows from anemic micro-optimization.