Refactoring From an Anemic Domain Model to a Rich Domain Model in .NET
A friendly, step-by-step guide to turning a data-only anemic domain model into a rich domain model in C# .NET, with rules living inside your objects.
Imagine you order a fresh fruit juice from a small shop near your home. You tell the shopkeeper, "One sweet lime juice, please." You do not walk behind the counter, grab the blender, pour the sugar yourself, and switch on the machine. The shopkeeper does all of that. They know the recipe. They know how much sugar is safe. They keep the rules inside their own work.
Now imagine the opposite. The shop only gives you an empty glass and a pile of raw ingredients. You must mix everything yourself, every single time, in every shop you visit. If one customer adds too much sugar, nobody stops them. This second shop is messy, and mistakes happen often.
In software, these two shops are two ways of building your domain objects. The messy "do it yourself" shop is called an anemic domain model. The careful shopkeeper who guards the recipe is a rich domain model. This article shows you how to move from the first to the second, step by step, in C# and .NET.
What is an anemic domain model?
An anemic domain model is a class that holds only data. It has properties with public getters and setters, and almost no behavior. All the real rules live somewhere else, usually in big service classes.
Martin Fowler named this pattern back in 2003 and called it an anti-pattern. The word "anemic" means weak or lacking blood. These objects are weak because they carry no logic of their own. They are just bags of getters and setters.
Here is a typical anemic Order class.
public class Order
{
public Guid Id { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public List<OrderItem> Items { get; set; } = new();
}
public class OrderItem
{
public string ProductName { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}Notice that anyone can write order.Status = "Anything" or order.Total = -500. Nothing stops them. The object cannot protect itself.
Now look at where the rules actually live. They sit far away, inside a service.
public class OrderService
{
public void AddItem(Order order, OrderItem item)
{
if (order.Status == "Shipped")
throw new InvalidOperationException("Cannot change a shipped order.");
order.Items.Add(item);
order.Total = order.Items.Sum(i => i.UnitPrice * i.Quantity);
}
}This works on day one. The trouble starts when a second service also adds items, and a third service changes the total a different way, and a fourth forgets the shipped check. The rules drift apart. Bugs creep in.
What is a rich domain model?
A rich domain model puts the rules back inside the object that owns the data. The Order class itself decides what is allowed. Other code must ask the order to do things, instead of reaching in and changing fields.
This matches the advice from Microsoft Learn: an entity should not have public setters. Changes happen through clear methods that describe the business action, like AddItem or MarkAsShipped.
The big difference is encapsulation. Encapsulation means hiding the inside of an object and only allowing safe, named actions from the outside. The juice shopkeeper encapsulates the recipe. Your Order should encapsulate its rules.
A quick comparison
Before we refactor, let us line up the two styles side by side.
| Topic | Anemic model | Rich model |
|---|---|---|
| Where rules live | In service classes | Inside the entity |
| Setters | Public, anyone can change | Private, controlled by methods |
| Object validity | Can be invalid | Always valid after each method |
| Duplication risk | High, rules repeat in services | Low, one trusted place |
| Reading the code | Logic is spread out | Logic reads like the business |
And here is when each one fits best.
| Situation | Best choice | Why |
|---|---|---|
| Simple CRUD, almost no rules | Anemic | Less code, easy to follow |
| Many changing business rules | Rich | One safe home for logic |
| Short-lived prototype | Anemic | Speed matters more |
| Long-lived core system | Rich | Safety and clarity matter more |
So an anemic model is not always wrong. For a tiny CRUD service it is fine. The pain appears when rules grow and scatter.
The refactoring journey
Let us walk through the steps to turn our weak Order into a strong one. We will go slowly so each move makes sense.
Refactoring steps
Steps
Close setters
Make setters private
Add factory
Create valid objects only
Add behavior methods
Name the business actions
Protect collections
Hide the list
Move rules in
Empty the service
Step 1: Close the public setters
The first move is to stop outsiders from changing fields directly. We make every setter private. Now the only code that can change an order is the order itself.
public class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public decimal Total { get; private set; }
private readonly List<OrderItem> _items = new();
}Right away the object is safer. Nobody can write order.Total = -500 from outside anymore. We also swapped the loose string Status for an enum OrderStatus, so only real statuses are possible.
Step 2: Create objects through a controlled path
If the constructor is open and empty, people can still build a broken order. So we add a constructor (or a small factory method) that checks the basics and refuses to create something invalid.
public class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
private Order() { } // for EF Core
public Order(Guid customerId)
{
if (customerId == Guid.Empty)
throw new ArgumentException("An order needs a real customer.");
Id = Guid.NewGuid();
Status = OrderStatus.Draft;
}
}A newborn order is now always valid. It starts as a Draft with a real customer. The private parameterless constructor is just for Entity Framework Core, which can use private members to rebuild objects from the database.
Step 3: Add behavior methods that speak the business language
This is the heart of the change. Each real action becomes a method with a clear name. The method holds the rule. Callers must go through it.
public void AddItem(string productName, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Only draft orders can be changed.");
if (quantity <= 0)
throw new ArgumentException("Quantity must be at least 1.");
_items.Add(new OrderItem(productName, quantity, unitPrice));
RecalculateTotal();
}
public void MarkAsShipped()
{
if (!_items.Any())
throw new InvalidOperationException("Cannot ship an empty order.");
Status = OrderStatus.Shipped;
}
private void RecalculateTotal() =>
Total = _items.Sum(i => i.UnitPrice * i.Quantity);Read those method names out loud: add item, mark as shipped. They sound like the business. This is the ubiquitous language idea from Domain-Driven Design. The code now tells the same story the shopkeeper would tell.
Step 4: Protect the collection
Our _items list is private, which is good. But callers still need to see the items, for example to show them on a screen. The trick is to expose a read-only view, not the real list. If we handed out the real list, anyone could call Items.Add(...) and skip our rules.
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();Now outside code can read the items but cannot add or remove them. The only way to add an item is through AddItem, which checks the rules first. Microsoft Learn recommends exactly this: child collections should be read-only from the outside.
Step 5: Empty out the service
With all the rules inside Order, the old OrderService becomes very thin. It now only loads the order, asks it to do something, and saves it. This thin layer is sometimes called an application service.
public class OrderAppService
{
private readonly IOrderRepository _orders;
public OrderAppService(IOrderRepository orders) => _orders = orders;
public async Task AddItemAsync(Guid orderId, string product, int qty, decimal price)
{
var order = await _orders.GetAsync(orderId);
order.AddItem(product, qty, price); // the rule lives here, not here-ish
await _orders.SaveAsync(order);
}
}The service no longer knows the rules. It just coordinates. If the rule for adding an item ever changes, you change it in one place: the Order class.
The aggregate root idea
Our Order is now what Domain-Driven Design calls an aggregate root. An aggregate is a small group of related objects that change together. In our case, the Order and its OrderItem children form one aggregate. The Order is the root, the single front door.
The rule is simple: nobody talks to an OrderItem directly from outside. They go through the Order. This keeps the whole group consistent, because the root checks every change.
Aggregate boundary
Steps
Caller
Calls root methods only
Order (root)
Checks rules, updates children
OrderItem
Changed only via root
OrderItem
Never touched directly
Think of the aggregate as a tiffin box. The lid is the root. You open the lid to put food in or take it out. You do not poke holes in the side of the box. One opening, one set of rules.
How Entity Framework Core fits in
A common worry is, "If my setters are private and my constructor is hidden, how does the database load my object?" The good news is that EF Core in .NET 10 handles this well. It can use private setters, backing fields like _items, and a private parameterless constructor.
You map the backing field in your configuration so EF Core knows how to fill the list.
public class OrderConfig : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
var nav = builder.Metadata.FindNavigation(nameof(Order.Items));
nav!.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}This tells EF Core to read and write the _items field directly, instead of going through the read-only Items property. Your encapsulation stays intact, and persistence still works. You do not need any extra library or framework for this. Plain EF Core and the standard .NET SDK are enough.
A few honest warnings
Moving to a rich model is a good idea for systems with real rules, but please keep these in mind.
- Do not over-engineer a simple CRUD app. If your "order" is just a row with no rules, a rich model adds work for little gain.
- Keep aggregates small. A giant aggregate that owns half the database becomes slow and hard to load.
- Refactor in small steps. Close setters first, then add one method at a time, and keep your tests green between each move.
- Avoid leaking the internal list. The most common slip is returning
_itemsdirectly instead of a read-only view.
You may have heard of message libraries like MediatR and MassTransit, which often appear in DDD examples for sending domain events. As of 2026 both have moved to a commercial license for many uses. You do not need either of them to build a rich domain model. They are optional extras, so check the license terms before adding them to a project.
Putting it all together
Here is the shape of the finished Order. The data is private. The rules are inside. The object is always valid.
| Member | Visibility | Purpose |
|---|---|---|
Id, Status, Total | Read-only from outside | Show state, never set directly |
Items | Read-only collection | View items, cannot edit list |
AddItem | Public method | Add an item with rules checked |
MarkAsShipped | Public method | Change status safely |
RecalculateTotal | Private method | Internal helper, hidden |
The class now reads like the business. A new teammate can open it and learn the rules of an order just by reading the method names. That is the real prize of a rich domain model: the code explains itself.
Quick recap
- An anemic domain model is a class with only data and no behavior. Rules live far away in services.
- A rich domain model puts the rules inside the object, protected by encapsulation.
- Refactor in small steps: make setters private, add a checking constructor, add named behavior methods, protect the collection, then empty the service.
- The
Orderbecomes an aggregate root: the single front door for itself and its child items. - Entity Framework Core supports private setters, backing fields, and private constructors, so your encapsulation and your database both stay happy.
- An anemic model is not always wrong. For simple CRUD it is fine. Go rich when business rules grow and start to scatter.
- You do not need MediatR or MassTransit (now commercially licensed) to build a rich model. Plain C# and the .NET SDK are enough.
References and further reading
- Martin Fowler: Anemic Domain Model
- Microsoft Learn: Designing a microservice domain model
- Microsoft Learn: Implementing a microservice domain model with .NET
- Code Maze: Aggregate Design in .NET
Related Posts
Value Objects in .NET: DDD Fundamentals Made Simple
Learn value objects in .NET with simple examples. Understand equality, immutability, records vs base class, and EF Core mapping in domain-driven design.
What Invariants Are and Why the Domain Model Enforces Them Best
Learn what invariants are in DDD with simple examples, and why your .NET domain model and aggregate roots are the safest place to keep business rules true.
From Anemic Models to Behavior-Driven Models: A Practical DDD Refactor in C#
Learn to refactor anemic C# classes into rich, behavior-driven domain models using DDD. A simple, step-by-step guide with diagrams and real code.
Refactoring Overgrown Bounded Contexts in Modular Monoliths (.NET)
Learn how to spot and split an overgrown bounded context in a .NET modular monolith using safe, step-by-step refactoring, with diagrams, tables and code.
CQRS Pattern in .NET: The Way It Should Have Been From the Start
A friendly, hands-on guide to the CQRS pattern in .NET 10. Learn commands, queries, handlers, diagrams, and when to actually use it.
From Transaction Scripts to Domain Models: A Refactoring Journey
Learn how to refactor messy transaction script code into a clean domain model in .NET 10. Simple examples, diagrams, tables, and EF Core code to guide your journey.