Rich Domain Model vs Anemic Domain Model in C#

Rich Domain Model vs Anemic Domain Model in C#

By

7 min read··

clean-architecturecsharpddd

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.

ConcernAnemic ModelRich Model
Business rulesImplemented in services or transaction scriptsImplemented in entities, value objects, and domain services
State changesCallers assign properties directlyCallers invoke operations that validate transitions
CollectionsOften exposed as mutable listsPrivate storage with read-only views and controlled mutations
TestingTest the service that owns each use caseTest local rules directly through the domain API
Best fitSimple CRUD and data-centric workflowsDomains with substantial rules and state transitions
CostCallers must consistently apply validationMore modeling and persistence configuration
Comparison showing the anemic model with all rules in OrderService mutating a data-bag Order, versus the rich model where the application service orchestrates and the Order aggregate holds rules and invariants

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.Status directly - 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 _lineItems directly
  • 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.

  • Aggregate Design in DDD - Rules, Boundaries, and Consistency

    Aggregates are the most important tactical pattern in Domain-Driven Design. They define consistency boundaries, enforce invariants, and protect your domain model. Here are the rules and practical guidance for designing aggregates in .NET.

  • Aggregate Root in DDD: Rules and Implementation

    The Aggregate Root is the gatekeeper of consistency in Domain-Driven Design. Here are the rules for designing aggregate roots and implementing them in C#.

  • The Always-Valid Domain Model

    An always-valid domain model enforces business invariants at construction and on every state change. Private constructors, validated value objects, and guarded methods reduce repeated validation while keeping persistence and concurrency checks explicit.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.