A rich domain model keeps business rules with the entities and value objects that own the state, while an anemic model stores data in objects and implements behavior in services. Use a rich model for meaningful invariants and state transitions. For straightforward CRUD, a simpler data model and transaction scripts can be sufficient.
Two Approaches to Domain Modeling
Martin Fowler described the Anemic Domain Model as an anti-pattern in 2003: it pays the cost of a domain object model while leaving its behavior in services.
Let's look at both approaches and understand when each one is actually the right choice.
| Concern | Anemic Model | Rich Model |
|---|---|---|
| Business rules | Implemented in services or transaction scripts | Implemented in entities, value objects, and domain services |
| State changes | Callers assign properties directly | Callers invoke operations that validate transitions |
| Collections | Often exposed as mutable lists | Private storage with read-only views and controlled mutations |
| Testing | Test the service that owns each use case | Test local rules directly through the domain API |
| Best fit | Simple CRUD and data-centric workflows | Domains with substantial rules and state transitions |
| Cost | Callers must consistently apply validation | More modeling and persistence configuration |
The Anemic Domain Model
An anemic model separates data from behavior. Entities are data containers - just properties with getters and setters. All logic lives in services.
// Anemic entity - just data
public class Order
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public List<OrderLineItem> LineItems { get; set; } = new();
public string Status { get; set; } = "Draft";
public decimal TotalAmount { get; set; }
public DateTime CreatedAt { get; set; }
}
// All logic in a service
public class OrderService
{
public void AddLineItem(Order order, Guid productId, decimal price, int quantity)
{
if (order.Status != "Draft")
throw new InvalidOperationException("Cannot modify non-draft order.");
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive.");
order.LineItems.Add(new OrderLineItem
{
ProductId = productId,
Price = price,
Quantity = quantity
});
order.TotalAmount = order.LineItems.Sum(li => li.Price * li.Quantity);
}
public void Cancel(Order order)
{
if (order.Status == "Cancelled")
throw new InvalidOperationException("Order already cancelled.");
if (order.Status == "Shipped")
throw new InvalidOperationException("Cannot cancel shipped order.");
order.Status = "Cancelled";
}
}
Problems:
- Anyone can modify
order.Statusdirectly - bypassing all validation - Business rules are scattered - which service validates what?
- Entities don't protect themselves - invalid states are easy to create
- Testing requires the service - you can't test order behavior in isolation
The Rich Domain Model
A rich model keeps data and behavior together. Entities enforce their own invariants.
The Order.cs excerpt uses application-defined IDs, immutable Money, domain events, and the Result types from the linked Result-pattern article.
Assume CustomerId and ProductId validate their underlying IDs at creation; a line item exposes no public mutation methods:
public sealed class Order : AggregateRoot
{
private readonly List<OrderLineItem> _lineItems = new();
private Order(Guid id, CustomerId customerId) : base(id)
{
CustomerId = customerId;
Status = OrderStatus.Draft;
CreatedAt = DateTime.UtcNow;
}
public CustomerId CustomerId { get; }
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; } = Money.Zero(Currency.Usd);
public DateTime CreatedAt { get; }
public IReadOnlyCollection<OrderLineItem> LineItems => _lineItems.AsReadOnly();
public static Order Create(CustomerId customerId)
{
ArgumentNullException.ThrowIfNull(customerId);
var order = new Order(Guid.NewGuid(), customerId);
order.RaiseDomainEvent(new OrderCreatedDomainEvent(order.Id));
return order;
}
public Result AddLineItem(ProductId productId, Money price, int quantity)
{
if (Status != OrderStatus.Draft)
{
return Result.Failure(OrderErrors.NotDraft);
}
if (quantity <= 0)
{
return Result.Failure(OrderErrors.InvalidQuantity);
}
ArgumentNullException.ThrowIfNull(productId);
ArgumentNullException.ThrowIfNull(price);
if (price.Currency != Currency.Usd)
{
return Result.Failure(new Error(
"Order.CurrencyMismatch", "Order prices must be in USD."));
}
var lineItem = new OrderLineItem(productId, price, quantity);
var newTotal = TotalAmount + lineItem.TotalPrice;
_lineItems.Add(lineItem);
TotalAmount = newTotal;
return Result.Success();
}
public Result Cancel()
{
if (Status == OrderStatus.Cancelled)
{
return Result.Failure(OrderErrors.AlreadyCancelled);
}
if (Status == OrderStatus.Shipped)
{
return Result.Failure(OrderErrors.CannotCancelShipped);
}
Status = OrderStatus.Cancelled;
RaiseDomainEvent(new OrderCancelledDomainEvent(Id));
return Result.Success();
}
}
Benefits:
- Private setters - state can only change through controlled methods
- Private constructor - creation goes through
Create()which enforces invariants - Read-only collections - external code can't modify
_lineItemsdirectly - Business rules encapsulated - the entity validates itself
- Domain events - the entity signals when something notable happens
- Result pattern - expected failures appear in the method contract
The new total is computed before mutating the collection, so a failed calculation does not leave a partially updated order.
This example fixes the order currency at USD; the Money type must enforce non-negative prices and currency-safe arithmetic.
Refactoring From Anemic to Rich
Here's the step-by-step approach for refactoring to a rich domain model:
Step 1: Make Setters Private
// Before
public string Status { get; set; }
// After
public OrderStatus Status { get; private set; }
This immediately breaks all code that directly modifies Status - which is exactly what you want.
It forces you to create proper methods.
Step 2: Add Domain Methods
Move logic from services into the entity:
// Before - logic in service
orderService.Cancel(order);
// After - logic on entity
order.Cancel();
Step 3: Protect Collections
// Before - anyone can Add/Remove
public List<OrderLineItem> LineItems { get; set; }
// After - controlled through methods
private readonly List<OrderLineItem> _lineItems = new();
public IReadOnlyCollection<OrderLineItem> LineItems => _lineItems.AsReadOnly();
Step 4: Replace Primitives With Value Objects
// Before
public decimal TotalAmount { get; set; }
public string Currency { get; set; }
// After
public Money TotalAmount { get; private set; }
Value objects centralize local validation when their constructors, factories, and public APIs enforce the same rules. They do not replace null handling, database constraints, or concurrency checks.
Step 5: Add Factory Methods
// Before - public constructor, any state possible
var order = new Order { Status = "Invalid", TotalAmount = -100 };
// After - controlled creation
var order = Order.Create(customerId);
When Is a Simpler Data Model Appropriate?
The anemic model isn't always wrong. It's the right choice when:
Simple CRUD operations. If your "business logic" is just saving data to a database and reading it back, a rich model adds complexity without value.
Data-centric applications. Reporting dashboards, admin panels, data import tools - these work with data, not domain behavior.
Small, simple domains. If your entities have no invariants to protect, there's nothing for a rich model to encapsulate.
When to Use a Rich Model
Complex business rules. If entities have invariants, state transitions, and validation rules, put them on the entity.
Multiple consumers of the same logic. If three services all check if (order.Status == "Draft") before modifying an order, that check belongs on the entity - once.
Long-lived projects. The investment in a rich model pays off over time as the codebase grows and business rules become more complex.
Domain-Driven Design. A rich model is useful for a complex core domain, but DDD also includes strategic modeling and boundaries. A supporting CRUD area can use a simpler implementation without invalidating those architectural decisions.
The Application Service's Role Changes
With a rich model, the Application layer becomes thinner:
// With anemic model - service does everything
public async Task<Result> CancelOrder(Guid orderId)
{
var order = await _repository.GetByIdAsync(orderId);
if (order is null)
return Result.Failure(new Error("Order.NotFound", "Order not found."));
if (order.Status == "Cancelled")
return Result.Failure(new Error("Order.AlreadyCancelled", "Already cancelled."));
if (order.Status == "Shipped")
return Result.Failure(new Error("Order.Shipped", "Cannot cancel shipped order."));
order.Status = "Cancelled";
await _unitOfWork.SaveChangesAsync();
await _eventBus.PublishAsync(new OrderCancelledEvent(orderId));
return Result.Success();
}
// With rich model - service orchestrates
public async Task<Result> CancelOrder(Guid orderId)
{
var order = await _repository.GetByIdAsync(orderId);
if (order is null)
return Result.Failure(new Error("Order.NotFound", "Order not found."));
var result = order.Cancel(); // Domain logic on entity
if (result.IsFailure)
return result;
await _unitOfWork.SaveChangesAsync(); // Persistence infrastructure captures events
return Result.Success();
}
The Application Service goes from implementing business logic to orchestrating domain objects.
SaveChangesAsync does not dispatch domain events by itself.
Configure a dispatcher explicitly, or persist outgoing events in an outbox with the order and deliver them afterward.
Use an EF Core concurrency token that changes for every aggregate modification when simultaneous requests can violate its rules.
Summary
A simple data model is sufficient for straightforward CRUD. A rich model gives complex business rules a controlled API and reduces the number of callers that must know each invariant.
If you're doing DDD or building a complex .NET application, invest in rich entities that:
- Protect their invariants
- Expose behavior through methods
- Use private setters and read-only collections
- Raise domain events for side effects
Start with the most important aggregate and work outward.
Thanks for reading.
And stay awesome!
Frequently Asked Questions
What is an anemic domain model?
An anemic domain model separates data from behavior: entities are property bags with public getters and setters, and all business logic lives in services. Martin Fowler described it as an anti-pattern because it gives up the encapsulation benefits of objects.
What is a rich domain model?
A rich domain model keeps data and behavior together. Entities have private setters, expose operations as methods, validate their own invariants, and raise domain events when something notable happens.
Is the anemic domain model always bad?
No. For simple CRUD applications, data-centric tools, and domains with no real invariants, an anemic model is simpler and perfectly adequate. The rich model pays off when business rules are complex.
How do you refactor from an anemic to a rich domain model?
Make setters private, move logic from services into entity methods, replace exposed collections with read-only views, replace primitives with value objects, and add factory methods for controlled creation.
Where does business logic go with a rich domain model?
Rules live in entities, value objects, and domain services for policies that do not fit one object. Application services orchestrate loading, domain operations, persistence, and returning results.



