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 Order get pushed out into OrderService or 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.

  1. No Single Source of Truth: Is the logic for shipping an order in OrderService? Or FulfillmentService? When the rules are scattered, you can never be sure you’ve found them all.
  2. Zero Encapsulation: The AnemicOrder is completely defenseless. Any piece of code anywhere in the application can set an order’s status to Shipped, whether it’s paid or not. The object can’t enforce its own rules.
  3. 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 the Status is by calling a method like Ship().
  • The business rules are right next to the data they protect. There is now one place to look for shipping logic.
  • The Order is 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 private parameterless 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 private List<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 Account balance 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 an Order, 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

About the Author

Abhinaw Kumar is a software engineer who builds real-world systems: from resilient ASP.NET Core backends to clean, maintainable Angular frontends. With over 11+ years in production development, he shares what actually works when you're shipping software that has to last.

Read more on the About page or connect on LinkedIn.

FAQ

Isn’t this just putting all my code into my domain models?

No. It’s about putting the logic that governs an object’s own state and rules inside that object. Logic for coordinating between objects or interacting with external systems (databases, APIs, email) still belongs in services.

Does this make my objects harder to test?

It actually makes them easier to test. You can unit test a rich domain object’s methods directly without needing to mock a dozen dependencies. You’re testing the core business logic in isolation.

Can I use this approach if my team prefers anemic domain models?

This approach is the direct opposite of an anemic domain model. The goal is to build rich models where data and behavior are combined. You can’t really do both, as they represent fundamentally different design philosophies.

Related Posts