The anemic domain model: when entities become data containers
We've all inherited codebases where business logic sprawls across service classes while domain entities sit empty, holding nothing but getters and setters. The anemic domain model has become one of the most pervasive anti-patterns in enterprise development, yet it persists because it's easy to build quickly and requires no upfront domain thinking. The anemic domain model: real-world lessons and examples teaches us that this approach creates hidden costs—scattered invariants, difficult testing, and systems where the rules live everywhere except where the data lives.
Over 87 days of exploring patterns in production systems, we've seen this trap claim countless projects. Today, we'll examine why it happens, where it breaks down, and how to recognize when it's appropriate versus when it's poisoning your codebase.
All logic lives in services
The classic anemic domain model funnels every operation into service classes. Your User entity becomes a plain data holder, and all rules—validation, state transitions, calculations—live in UserService, UserController, sometimes even in multiple places.
Consider a real payment processing system we worked on:
// Anemic entity—just a container
class Payment {
id: string;
userId: string;
amount: number;
status: string;
createdAt: Date;
getId() { return this.id; }
getAmount() { return this.amount; }
getStatus() { return this.status; }
setStatus(status: string) { this.status = status; }
// ... more getters and setters
}
// All logic lives here
class PaymentService {
async processPayment(payment: Payment, processor: PaymentGateway) {
if (payment.getStatus() !== 'pending') {
throw new Error('Only pending payments can be processed');
}
if (payment.getAmount() <= 0) {
throw new Error('Invalid amount');
}
if (payment.getAmount() > 10000) {
throw new Error('Amount exceeds limit');
}
const result = await processor.charge(
payment.getUserId(),
payment.getAmount()
);
payment.setStatus('completed');
await this.paymentRepository.save(payment);
return result;
}
async refundPayment(payment: Payment) {
if (payment.getStatus() !== 'completed') {
throw new Error('Can only refund completed payments');
}
// ... refund logic
}
async cancelPayment(payment: Payment) {
if (payment.getStatus() !== 'pending') {
throw new Error('Can only cancel pending payments');
}
payment.setStatus('cancelled');
await this.paymentRepository.save(payment);
}
}
The problem emerges immediately: business rules about payment states and transitions live in the service. If you add a new feature—say, partial refunds—the logic goes in another service method. When a developer needs to understand what states a payment can legally transition to, they're hunting through multiple service classes. The entity itself remains silent about its own constraints.
This pattern scales poorly. With three services touching Payment, you have three places enforcing (or forgetting to enforce) the same rule. The source of truth fragments.
Invariants are enforced nowhere
An invariant is a rule that must always be true. In the anemic model, invariants have no home. They're implicit assumptions scattered across your codebase, enforced only through programmer discipline.
We debugged a production issue in a ride-hailing system where a trip's total fare fell below its base fare after a discount was applied. The bug lived because the discount logic sat in one place, but the validation check lived in another—and someone had skipped the validation in a new refund flow.
// Entity: no protection
class Trip {
id: string;
baseFare: number;
discountAmount: number = 0;
finalFare: number;
setDiscountAmount(amount: number) {
this.discountAmount = amount;
this.finalFare = this.baseFare - amount;
}
}
// Rule checked here
class TripPricingService {
applyDiscount(trip: Trip, discount: number) {
if (discount >= trip.baseFare) {
throw new Error('Discount cannot exceed base fare');
}
trip.setDiscountAmount(discount);
}
}
// Rule forgotten here
class RefundService {
processRefund(trip: Trip, refundAmount: number) {
// Developer forgot the invariant check
trip.setDiscountAmount(trip.discountAmount + refundAmount);
// Now finalFare can be negative!
}
}
With a rich domain model, the invariant lives in the entity itself:
class Trip {
id: string;
private baseFare: number;
private discountAmount: number = 0;
private finalFare: number;
constructor(baseFare: number) {
if (baseFare <= 0) throw new Error('Base fare must be positive');
this.baseFare = baseFare;
this.finalFare = baseFare;
}
applyDiscount(amount: number) {
if (amount >= this.baseFare) {
throw new Error('Discount cannot exceed base fare');
}
this.discountAmount = amount;
this.recalculateFinalFare();
}
processRefund(refundAmount: number) {
// Same rule, enforced here too
const newDiscount = this.discountAmount + refundAmount;
if (newDiscount >= this.baseFare) {
throw new Error('Total deduction cannot exceed base fare');
}
this.discountAmount = newDiscount;
this.recalculateFinalFare();
}
private recalculateFinalFare() {
this.finalFare = this.baseFare - this.discountAmount;
}
getFinalFare(): number { return this.finalFare; }
}
Now the invariant is encoded once. Any code path that modifies the trip's state must go through methods that enforce the rule. The entity guards itself.
Rich models keep rules with data
A rich domain model colocates behavior with the data it protects. The entity becomes responsible for its own validity. This is harder to architect upfront but pays dividends in maintainability and correctness.
We redesigned an e-commerce order system around this principle. Orders have complex state machines—pending, paid, shipped, delivered, disputed—with context-dependent rules for each transition.
class Order {
private id: string;
private customerId: string;
private items: OrderItem[] = [];
private status: OrderStatus = OrderStatus.PENDING;
private paidAt: Date | null = null;
private totalAmount: number = 0;
constructor(customerId: string) {
if (!customerId) throw new Error('Customer ID required');
this.customerId = customerId;
}
addItem(product: Product, quantity: number) {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Cannot add items to a non-pending order');
}
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
const item = new OrderItem(product.getId(), product.getPrice(), quantity);
this.items.push(item);
this.recalculateTotal();
}
markAsPaid(paymentId: string, amount: number) {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Only pending orders can be marked as paid');
}
if (amount !== this.totalAmount) {
throw new Error('Payment amount does not match order total');
}
if (this.items.length === 0) {
throw new Error('Cannot pay for an empty order');
}
this.status = OrderStatus.PAID;
this.paidAt = new Date();
}
ship() {
if (this.status !== OrderStatus.PAID) {
throw new Error('Can only ship paid orders');
}
this.status = OrderStatus.SHIPPED;
}
// State query methods for read operations
isPaid(): boolean { return this.status === OrderStatus.PAID; }
canBeShipped(): boolean { return this.status === OrderStatus.PAID; }
getTotalAmount(): number { return this.totalAmount; }
private recalculateTotal() {
this.totalAmount = this.items.reduce(
(sum, item) => sum + item.getLineTotal(),
0
);
}
}
By encoding the state machine in the entity, we eliminate coordination bugs. Services now orchestrate—they call methods in the right order—but they can't circumvent the rules. A developer can read the Order class and understand all valid transitions. Tests focus on behavior, not setter chains.
Fine for simple CRUD apps
We'd be remiss not to acknowledge the truth: anemic models work fine for straightforward CRUD applications with minimal business logic.
A simple blog platform with posts, comments, and basic user roles? Anemic models are appropriate. The validation is shallow (title not empty, email format valid), and state transitions are rare. The overhead of a rich model adds friction without benefit.
// Perfectly reasonable for a simple CRUD app
class BlogPost {
id: string;
authorId: string;
title: string;
content: string;
published: boolean = false;
createdAt: Date;
updatedAt: Date;
getId() { return this.id; }
setTitle(title: string) { this.title = title; }
// ... straightforward getters/setters
}
class BlogPostService {
async createPost(authorId: string, title: string, content: string) {
if (!title || title.trim().length === 0) {
throw new Error('Title required');
}
const post = new BlogPost();
post.authorId = authorId;
post.title = title;
post.content = content;
post.createdAt = new Date();
return this.postRepository.save(post);
}
async publishPost(postId: string) {
const post = await this.postRepository.findById(postId);
post.published = true;
post.updatedAt = new Date();
return this.postRepository.save(post);
}
}
The key question is: does the domain have inherent complexity? If your rules are simple and linear, anemic models keep overhead low. If your domain has state machines, interdependent rules, or complex invariants, rich models prevent bugs and clarify intent.
Common mistakes
Mistake 1: Building rich models when the domain is simple. If you're writing a CRUD admin panel, over-architecting the domain is waste. Start simple. Refactor when the pain appears.
Mistake 2: Making entities responsible for persistence. Rich entities should know business rules, not database details. Services or repositories handle persistence. The entity enforces invariants, the repository enforces consistency.
Mistake 3: Putting conditional logic in service method names. We've seen codebases with markAsRefundedIfEligible() and processPaymentUnlessInDispute(). These hide the rules. Either the service enforces the rule explicitly with exceptions, or the entity prevents invalid transitions.
Mistake 4: Leaving a mix of rich and anemic entities. Hybrid codebases confuse developers. If most entities are rich, a newly added anemic one becomes a trip hazard. Establish a pattern early and stick to it.
Mistake 5: Confusing getters/setters with encapsulation. An entity with 20 getters and setters is still anemic. True encapsulation means the entity provides intent-based operations (applyDiscount(), markAsShipped()) rather than property access.
Key takeaways
- Anemic models concentrate all logic in services, leaving entities as hollow data containers with no responsibility for their own validity.
- Invariants scattered across multiple services are invariants enforced nowhere—the same rule is forgotten in the edge case you haven't found yet.
- Rich domain models colocate behavior with data, making invalid states impossible and rules visible in one place.
- Rich models are not always the right choice: simple CRUD apps with minimal business logic benefit from the simplicity of anemia.
- The decision point is complexity. When your domain has state machines, interdependent rules, or invariants that matter, move logic into the entity itself.
- Hybrid approaches (some rich, some anemic entities) create cognitive friction. Pick a pattern and apply it consistently.
- Rich entities still defer persistence to repositories and services—encapsulation is about business rules, not storage logic.






Comments
Loading comments…