Anemic domain model anti-pattern
Learn why separating all business logic from domain objects into service classes produces procedural code disguised as OOP, and how a rich domain model fixes it.
TL;DR
- An anemic domain model has objects that are pure data containers (getters and setters) with all business logic living in service classes that manipulate them from the outside.
- Martin Fowler named this anti-pattern in 2003 and it remains the most common OOP anti-pattern in enterprise Java codebases.
- The symptom: your domain objects could be replaced with
HashMapor plain records without any loss of behavior. If you remove getters and setters, nothing is left. - The fix: move behavior into the domain object. An
Ordershould know how to apply a discount, cancel itself, and calculate its total, not let a service do those things to it from outside. - The key principle is Tell, Don't Ask: tell an object what to do rather than asking for its data and doing the work externally.
The Problem
A typical anemic Order in an enterprise codebase:
// Anemic: a struct with getters and setters, zero behavior
public class Order {
private String id;
private OrderStatus status;
private List<OrderItem> items;
private double discountPercent;
private BigDecimal total;
public String getId() { return id; }
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus s) { this.status = s; }
public List<OrderItem> getItems() { return items; }
public double getDiscountPercent() { return discountPercent; }
public void setDiscountPercent(double d) { this.discountPercent = d; }
public BigDecimal getTotal() { return total; }
public void setTotal(BigDecimal t) { this.total = t; }
}
All the business logic lives in a service that reaches into the data bag:
// Service knows all the rules. Order knows nothing.
public class OrderService {
public void applyDiscount(Order order, String code) {
if (order.getStatus() != OrderStatus.PENDING)
throw new IllegalStateException("Order not pending");
Discount d = promotionService.getDiscount(code);
if (d.minOrderValue().compareTo(calculateTotal(order)) > 0)
throw new IllegalStateException("Minimum not met");
order.setDiscountPercent(d.percent());
order.setTotal(calculateTotal(order)
.multiply(BigDecimal.ONE.subtract(
BigDecimal.valueOf(d.percent() / 100.0))));
}
public void cancelOrder(Order order) {
if (order.getStatus() == OrderStatus.SHIPPED)
throw new IllegalStateException("Cannot cancel shipped");
if (order.getStatus() == OrderStatus.DELIVERED)
throw new IllegalStateException("Cannot cancel delivered");
order.setStatus(OrderStatus.CANCELLED);
}
public BigDecimal calculateTotal(Order order) {
return order.getItems().stream()
.map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
This is procedural programming with class syntax. Order is a passive data bag. OrderService has all the knowledge. I've seen this pattern in every Spring Boot codebase I've reviewed in the last five years.
The real damage shows up in testing. To verify that "you cannot apply a discount to a shipped order," you need to instantiate OrderService with all its dependencies (promotionService, etc.) just to test a rule that depends only on Order's status field. With a rich model, the same test is three lines: create an order, ship it, call applyDiscount(), assert it throws.
The danger is clear: anyone can call order.setStatus(DELIVERED) directly, bypassing every business rule. The domain object cannot protect its own invariants.
Why It Happens
The anemic model is the default outcome of how most developers learn to write code. The frameworks encourage it, and nobody pushes back until the codebase reaches a certain size.
- Framework tutorials teach it. Spring Boot, Rails, and Django tutorials all separate "entities" (data) from "services" (logic). Developers internalize this as the correct architecture.
- Database-driven thinking. When you start with the database schema and generate entity classes, those classes naturally mirror table rows: columns become fields, fields get getters and setters.
- "Services are where logic goes." Teams adopt a blanket rule that business logic belongs in services. Nobody questions which logic belongs in the entity itself.
- Setter convenience. Public setters make it easy to construct and modify objects. Removing them feels restrictive until you see the bugs they enable.
- Code generation tools. Lombok's
@Datagenerates all getters and setters automatically. It is the fastest path to an anemic model because it requires zero thought about encapsulation.
Fowler's original critique
Martin Fowler defined this anti-pattern in 2003: "The fundamental horror of this anti-pattern is that it's so contrary to the basic idea of object-oriented design, which is to combine data and process together." The objects look like OOP. They behave like C structs.
How to Detect It
| Signal | What It Means | How to Check |
|---|---|---|
| Entity classes with only getters/setters | Zero encapsulated behavior | Search for classes where every method starts with get or set |
| Service classes with entity-name prefix | OrderService does what Order should do | If OrderService.calculateTotal(Order) exists, the total logic belongs on Order |
| Business rules duplicated in controllers | Invariants are not protected at the source | Grep for order.setStatus calls outside the domain layer |
| Tests need full service wiring to validate simple rules | "Cancel only if pending" requires OrderService + its dependencies | If testing a rule requires 3+ mocks, the rule is in the wrong place |
| Entity has no unit tests | Nothing to test on a data bag | Check if entity classes have corresponding test files |
The Fix
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.