Table of Contents
You open up a class called Order.cs and all you see are 20 properties with { get; set; }. There are no methods, just data. So you wonder, where’s the logic to cancel an order?
You run a global search for “Cancel” and find it in three places: OrderService, AdminService, and a creepy old LegacyOrderManager.cs. Each one has its own slightly different implementation of the cancellation rules.
This is an anemic domain model, and it’s a code smell I see in projects all the time. It’s a sign that your business logic is scattered, duplicated, and probably full of bugs.
TL;DR
- Anemic models are just data bags. They’re classes with a bunch of public getters and setters, but no real behavior or business logic.
- The logic ends up in bloated service classes. All the rules that should belong to the
Orderget pushed out intoOrderServiceor similar classes. - This is dangerous. It scatters your business rules everywhere, making it easy to create bugs and inconsistencies because there’s no single source of truth.
- The fix is a “rich” domain model. This is where you combine the data (properties) and the behavior (methods) that operates on that data into the same class.
- Yes, you can (and should) do this with EF Core. The idea that entities must be dumb property bags for the sake of persistence is a myth.
The Classic Anemic Order Class
An anemic model separates data from behavior. The Order class holds the data, but an OrderService holds all the logic. It looks something like this.
Here’s the data bag:
// AnemicOrder.cs - It just holds data.
public class AnemicOrder
{
public int Id { get; set; }
public List<OrderItem> Items { get; set; } = new();
public OrderStatus Status { get; set; }
public DateTime? PaidAt { get; set; }
public DateTime? ShippedAt { get; set; }
}
And here’s the bloated service class that has to do all the thinking for it:
// AnemicOrderService.cs - All the logic is in here.
public class AnemicOrderService
{
public void ShipOrder(AnemicOrder order)
{
// Lots of external checks... what if we miss one?
if (order.Status != OrderStatus.Paid)
{
throw new InvalidOperationException("Cannot ship an unpaid order.");
}
if (!order.Items.Any())
{
throw new InvalidOperationException("Cannot ship an empty order.");
}
order.Status = OrderStatus.Shipped; // Anyone can set this from anywhere
order.ShippedAt = DateTime.UtcNow;
// ... save changes, etc.
}
}
I once spent two days tracking down a bug where orders were shipped without payment. The problem? A developer added a new service that needed to ship an order, called order.Status = OrderStatus.Shipped directly, and completely bypassed the AnemicOrderService and its payment check. The AnemicOrder object itself had no way to protect its own state. It was a ticking time bomb.
So, What’s the Big Deal?
This pattern is basically procedural code wearing an object-oriented trench coat. It causes real problems in any system that has meaningful business rules.
- No Single Source of Truth: Is the logic for shipping an order in
OrderService? OrFulfillmentService? When the rules are scattered, you can never be sure you’ve found them all. - Zero Encapsulation: The
AnemicOrderis completely defenseless. Any piece of code anywhere in the application can set an order’s status toShipped, whether it’s paid or not. The object can’t enforce its own rules. - Inconsistencies and Bugs: This is the direct result of the first two points. When logic is duplicated or separated from the data it relates to, you will eventually have bugs caused by inconsistent state.
This anti-pattern is a direct consequence of ignoring the Tell, Don’t Ask principle. Instead of telling the order to ship itself, the service asks the order for all its data and then makes a decision.
The Fix: Making Your Domain Objects Actually Do Something
The solution is to move the logic back into the domain object where it belongs. This creates a “rich” domain model. Data and behavior live together.
Let’s fix that Order class.
// Order.cs - A rich domain entity
public class Order
{
public int Id { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
public OrderStatus Status { get; private set; }
public DateTime? PaidAt { get; private set; }
public DateTime? ShippedAt { get; private set; }
// Constructor can enforce initial rules
private Order() { }
public static Order Create(Customer customer, List<OrderItem> items)
{
if (customer is null || !items.Any())
{
throw new ArgumentException("An order must have a customer and items.");
}
// ... create a valid new order
return new Order { Status = OrderStatus.Pending, _items = items };
}
// Behavior and business rules live here!
public void MarkAsPaid()
{
if (Status != OrderStatus.Pending)
{
throw new InvalidOperationException("Order must be in pending state to be paid.");
}
Status = OrderStatus.Paid;
PaidAt = DateTime.UtcNow;
}
public void Ship()
{
// The object protects its own state.
if (Status != OrderStatus.Paid)
{
throw new InvalidOperationException("Cannot ship an unpaid order.");
}
Status = OrderStatus.Shipped;
ShippedAt = DateTime.UtcNow;
}
}
Notice a few things:
- Setters are
private. The only way to change theStatusis by calling a method likeShip(). - The business rules are right next to the data they protect. There is now one place to look for shipping logic.
- The
Orderis always in a valid state. You can’t accidentally create an invalid one.
And what happens to the service? It becomes incredibly simple. Its job is just to coordinate.
public class OrderService
{
private readonly AppDbContext _context;
// ... constructor ...
public async Task ShipOrderAsync(int orderId)
{
var order = await _context.Orders.FindAsync(orderId);
// Tell the order to ship itself.
order.Ship();
// The service's only job is to orchestrate.
await _context.SaveChangesAsync();
}
}
This is so much cleaner and safer. The OrderService doesn’t need to know the rules for shipping an order; it just needs to know that an order can be shipped.
Mythbusting: Rich Models and EF Core
“But I can’t do this because Entity Framework Core needs public parameterless constructors and public setters!”
This is one of the most persistent myths I hear. It’s just not true anymore. EF Core is smart enough to handle rich domain models perfectly well.
- Private Setters: EF Core has been able to map to private setters for years. It works out of the box.
- Constructors: EF Core can use a constructor with parameters, or even a
privateparameterless constructor. It finds one that works. - Backing Fields: You can expose a collection as
IReadOnlyCollection<T>to prevent anyone from adding items to it, while telling EF Core to use the privateList<T>field behind the scenes.
Here’s how you’d configure the Items collection from our Order class:
// In your DbContext's OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(builder =>
{
var navigation = builder.Metadata.FindNavigation(nameof(Order.Items));
// Tell EF Core to use the private "_items" field
navigation.SetPropertyAccessMode(PropertyAccessMode.Field);
});
}
Expert Insight: Don’t let your persistence tool dictate your domain model design. EF Core is flexible enough to support good design practices like encapsulation. There’s no excuse for creating anemic entities just because they map one-to-one with database columns.
The Takeaway: Stop Writing Data Bags
Your domain objects should be the core of your application—the primary keepers of business logic. Service classes are just helpers that orchestrate operations between domain objects and infrastructure (like the database or an email service).
Here’s my rule of thumb for deciding where logic goes:
- Does this logic enforce a rule about the object’s internal consistency? (e.g., An
Accountbalance can’t go below zero). Put it in the domain object. - Does this logic involve coordinating between multiple domain objects or external systems? (e.g., Get a
Customer, get anOrder, call a payment gateway API, save both objects). Put it in a service.
An anemic domain model is often a sign that your application’s most important asset—its business logic—isn’t being cared for. Next time you create a class, ask yourself what it does, not just what it holds.
References
- AnemicDomainModel by Martin Fowler
- Domain-Driven Design with Entity Framework Core (Microsoft Docs)
- Sack the Anemic Domain Model by Jimmy Bogard
