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.
Think about an ATM machine at your bank. You walk up to it and you cannot just open the machine and grab cash. You can only do what the machine allows. You insert your card, type your PIN, and ask for 2000 rupees. If your balance is too low, the machine says no. The machine protects the money inside it.
Now imagine a different machine. This one is just an open steel box full of cash sitting on the road. Anyone can take money, add money, or change the balance written on a sticky note. There are no rules. That box is scary, right? Anything can go wrong.
In C#, an anemic domain model is like that open box. It is a class that only holds data, with public setters that let anyone change anything. A rich, behavior-driven model is like the ATM. It guards its own data and only lets changes happen through safe, well-named methods.
In this post we will take an anemic Order class and slowly turn it into a rich one. We will use plain C# (C# 14, which ships with .NET 10, the current LTS release). No magic libraries needed.
What an anemic model looks like
Here is a typical anemic Order. It is just data. Every property has a public getter and setter.
// Anemic: just a bag of data, no rules inside
public class Order
{
public Guid Id { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public List<OrderLine> Lines { get; set; } = new();
}Where do the rules live? In a big service class far away from the data.
public class OrderService
{
public void AddLine(Order order, Product product, int quantity)
{
// Rule checks scattered here, not in the Order
if (order.Status == "Shipped")
throw new Exception("Cannot change a shipped order");
if (quantity <= 0)
throw new Exception("Quantity must be positive");
order.Lines.Add(new OrderLine(product.Id, product.Price, quantity));
order.Total = order.Lines.Sum(l => l.Price * l.Quantity);
}
}This looks fine at first. But it hides a real problem. Nothing stops another developer from writing order.Status = "Shipped" directly, or order.Total = 0, anywhere in the codebase. The rules only run if you remember to call the service. The data is not protected.
Why this hurts as the app grows
When an app is small, the anemic style feels quick. But as more rules appear, things go wrong. Pricing logic, discount rules, stock checks, and database writes all get jammed into one giant service. The same checks get copied into many places. One developer fixes a bug in one copy and forgets the others.
Here is a quick comparison of the two styles.
| Question | Anemic model | Rich model |
|---|---|---|
| Where do rules live? | In services, far from data | Inside the entity itself |
| Can data be set invalidly? | Yes, public setters | No, setters are private |
| Are rules duplicated? | Often, across services | No, one place |
| Easy to unit test? | Harder, needs services | Easy, test the entity |
| Good for tiny CRUD apps? | Yes, simple and fast | Slight overkill |
The key idea from Domain-Driven Design (DDD) is simple. Put the rules where the data lives. Stock checks, discounts, and status changes belong inside the Order, not in a service beside it.
Symptoms that say refactor now
Steps
Copied rules
Same check in many services
Invalid data bugs
Status set wrongly
Hard tests
Must build whole service
Fat services
One class does everything
Step 1: Lock the doors with private setters
The first move is to stop the outside world from changing data directly. We make the setters private. Now only the Order class itself can change its own fields.
public class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public decimal Total { get; private set; }
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
// EF Core needs a constructor; keep it private for safety
private Order() { }
public Order(Guid id)
{
Id = id;
Status = OrderStatus.Draft;
}
}Notice three small but powerful changes:
private setmeans outsiders can read the value but cannot change it.- The list is now
_lines(private) and exposed as a read-only view. Nobody can add or remove lines from outside. Statusis now anenum, not a loosestring. You cannot type"Shiped"by mistake anymore.
Step 2: Move one rule inside the model
Now we move behaviour in, one rule at a time. Do not rewrite everything at once. Start with a single method. We will move AddLine from the service into the Order itself.
public void AddLine(Guid productId, decimal price, int quantity)
{
// Rule: you cannot change a shipped order
if (Status == OrderStatus.Shipped)
throw new InvalidOperationException("A shipped order cannot change.");
// Rule: quantity must be positive
if (quantity <= 0)
throw new ArgumentException("Quantity must be greater than zero.");
_lines.Add(new OrderLine(productId, price, quantity));
RecalculateTotal();
}
private void RecalculateTotal()
{
Total = _lines.Sum(line => line.Price * line.Quantity);
}See how nice this reads? The rule and the data sit together. The Total is always correct because it is recalculated inside the method every time a line is added. No caller can forget to update it. The method name AddLine speaks the language of the business. This is what "behavior-driven" means: the model has verbs, not just nouns.
Step 3: Replace primitives with value objects
Our Order uses a raw decimal for money. That is a small trap called primitive obsession. A decimal does not know it is money. You could accidentally add a price to a quantity. A value object fixes this. It is a small immutable type that wraps a value and protects it.
public readonly record struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("Money cannot be negative.");
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency is required.");
Amount = amount;
Currency = currency;
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies.");
return new Money(Amount + other.Amount, Currency);
}
}A value object has no identity. Two Money values of 100 INR are equal because their contents are equal, not because they are the same object. Using a record struct gives us that equality for free. Now money cannot be negative, and you cannot mix rupees with dollars by mistake. The rule is built into the type itself.
| Concept | What it is | Equality is based on | Example |
|---|---|---|---|
| Entity | Has identity, changes over time | Its Id | Order, Customer |
| Value object | No identity, immutable | Its values | Money, Address, Email |
| Aggregate root | Entity that guards a group | Its Id | Order guarding its Lines |
Step 4: Make the Order an aggregate root
An aggregate is a small group of objects that belong together and change together. The Order and its OrderLine items form one aggregate. The Order is the aggregate root. The rule is strict and helpful: the outside world only talks to the root. Nobody reaches inside to grab a line and change it. This keeps the whole group consistent.
Because every change goes through the root, the root can always keep its invariants true. An invariant is a rule that must never be broken, like "the total always equals the sum of the lines" or "a shipped order cannot be edited." The root is the guard that protects these promises.
Here is a state rule expressed clearly. An order moves through states, and only some moves are allowed.
We turn that diagram into code with small, well-named methods. Each one checks the current state first.
public void Place()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Only a draft order can be placed.");
if (_lines.Count == 0)
throw new InvalidOperationException("Cannot place an empty order.");
Status = OrderStatus.Placed;
}
public void Ship()
{
if (Status != OrderStatus.Placed)
throw new InvalidOperationException("Only a placed order can be shipped.");
Status = OrderStatus.Shipped;
}Now it is impossible to ship a draft order. The model refuses. The rule cannot be skipped or forgotten because there is no other way to change the status.
Step 5: Slim down the application layer
After moving the rules into the model, the service class becomes tiny. It no longer holds business logic. It just orchestrates: it loads the order, calls a method, and saves. This is the goal. The application layer coordinates, while the domain decides.
public class PlaceOrderHandler
{
private readonly IOrderRepository _repository;
public PlaceOrderHandler(IOrderRepository repository)
=> _repository = repository;
public async Task Handle(Guid orderId)
{
Order order = await _repository.GetById(orderId);
order.Place(); // all the rules live inside here
await _repository.Save(order);
}
}Look how thin and calm this handler is. No if checks about status. No total math. The handler trusts the Order to protect itself. If a rule is broken, the Order throws, and the handler does not even need to know the details.
The refactor journey, step by step
Steps
Lock data
private set and read-only lists
Move one rule
AddLine into the entity
Add value objects
Money, Email, Address
Form aggregate
Order guards its lines
Thin the service
Just load, call, save
How testing gets easier
One quiet win of rich models is testing. With an anemic model, to test a rule you had to build the whole service, often with fake databases. With a rich model, you test the entity directly. No mocks, no setup.
[Fact]
public void Ship_throws_when_order_is_still_a_draft()
{
var order = new Order(Guid.NewGuid());
order.AddLine(Guid.NewGuid(), price: 100m, quantity: 2);
// The order is Draft, not Placed, so Ship must fail
Assert.Throws<InvalidOperationException>(() => order.Ship());
}This test is fast and clear. It reads like a sentence. It does not touch a database. The rule it checks lives right next to the code that enforces it, so the test is honest about what the system really does.
A note on libraries and licensing
Many DDD tutorials reach for libraries like MediatR or MassTransit to send commands and events. They are good tools, but as of recent versions they moved to a commercial license for many uses. So check the license and pricing before you add them to a paid product. The good news is that the heart of a rich domain model needs none of them. Private setters, constructors that validate, value objects, and well-named methods are all plain C#. You can get most of DDD's value with no extra dependency at all.
When you should not bother
Be honest about your app. If you are building a simple admin screen that just saves rows to a table with almost no rules, an anemic model is perfectly fine. It is faster to write and easy to read. DDD shines when the business rules are rich and important: orders, payments, bookings, inventory. Use the heavy tools only where the complexity earns them. Starting simple and refactoring later, one rule at a time, is a completely valid and smart path.
Quick recap
- An anemic model is just data with public setters. Rules live elsewhere, so the data is unguarded and bugs creep in as the app grows.
- A rich, behavior-driven model keeps rules next to the data using private setters and well-named methods. It works like an ATM, not an open cash box.
- Refactor in small steps: lock data with private setters, move one rule in, add value objects, form an aggregate, then slim the service.
- Value objects like
Moneykill primitive obsession and build rules into types. - The aggregate root is the only door into a group of objects, so it can always keep its invariants true.
- Rich models make testing easy because you test the entity directly, no service or database needed.
- You do not need MediatR or MassTransit (now commercially licensed) to do this. Plain C# is enough.
- For tiny CRUD apps, an anemic model is fine. Refactor only when the rules grow and start repeating.
References and further reading
- Implementing a microservice domain model with .NET (Microsoft Learn)
- From Anemic Models to Behavior-Driven Models: A Practical DDD Refactor in C# (Milan Jovanović)
- Refactoring From an Anemic Domain Model To a Rich Domain Model (Milan Jovanović)
- Value Objects in .NET (DDD Fundamentals)
- Anemic vs Rich Models in ASP.NET Core (NikolaTech)
Related Posts
C# init-only and required Properties: A Beginner's Guide
Learn C# init-only and required properties with simple analogies, diagrams, and code. Build safe, immutable objects that are filled correctly every time.
5 Awesome C# Refactoring Tips to Write Cleaner Code
Learn 5 simple C# refactoring tips with examples in modern C# 14. Make your code cleaner, safer, and easier to read using guard clauses and more.
How to Write Elegant Code with C# Pattern Matching
Learn C# pattern matching the easy way. Use is, switch expressions, property and list patterns to write clean, readable, and elegant .NET code.
Why I Switched to Primary Constructors for DI in C#
A friendly guide on why primary constructors made my C# dependency injection cleaner, with simple examples, diagrams, tables, and honest trade-offs.
Why I Write Tall LINQ Queries: Readable C# Pipelines
Learn why writing tall, one-operator-per-line LINQ queries in C# makes your code easier to read, debug, and review. Beginner friendly with diagrams.
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.