Skip to main content
SEMastery
.NET Coreintermediate

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.

12 min readUpdated December 10, 2025

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.

In an anemic design, data and rules are split apart, so the data is unguarded

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.

QuestionAnemic modelRich model
Where do rules live?In services, far from dataInside the entity itself
Can data be set invalidly?Yes, public settersNo, setters are private
Are rules duplicated?Often, across servicesNo, one place
Easy to unit test?Harder, needs servicesEasy, test the entity
Good for tiny CRUD apps?Yes, simple and fastSlight 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

Copied rules
Invalid data bugs
Hard tests
Fat services

Steps

1

Copied rules

Same check in many services

2

Invalid data bugs

Status set wrongly

3

Hard tests

Must build whole service

4

Fat services

One class does everything

When you see these, your anemic model has outgrown its size

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 set means 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.
  • Status is now an enum, not a loose string. You cannot type "Shiped" by mistake anymore.
With private setters, all changes must pass through the entity's own methods

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.

ConceptWhat it isEquality is based onExample
EntityHas identity, changes over timeIts IdOrder, Customer
Value objectNo identity, immutableIts valuesMoney, Address, Email
Aggregate rootEntity that guards a groupIts IdOrder 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.

The aggregate root is the single door into the group; lines are protected inside

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.

An order can only move through valid states; the model blocks the rest

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

Lock data
Move one rule
Add value objects
Form aggregate
Thin the service

Steps

1

Lock data

private set and read-only lists

2

Move one rule

AddLine into the entity

3

Add value objects

Money, Email, Address

4

Form aggregate

Order guards its lines

5

Thin the service

Just load, call, save

Move in small, safe steps; never a big rewrite

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 Money kill 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

Related Posts