• About Me
  • Development Posts
  • Development Videos
Mario Mamalis
Category:

Software Architecture

Development PostsSoftware Architecture

Anemic vs. Rich Domain Models: When Better Code Becomes Too Much Code

by Mario Mamalis May 15, 2026
written by Mario Mamalis
Anemic Vs. Rich Domain

A lot of software architecture debates sound more absolute than they are. One of the classic ones is this: should your domain model be rich, with behavior and business rules inside the entities, or should it be anemic, with entities mostly acting as data structures while handlers, services, validators, and workflows do the real work?

The “correct” architectural answer is usually that rich domain models are better. They protect invariants, keep business rules close to the state they govern, and make the model harder to misuse. And honestly, that is true.

But there is another truth that does not get said enough: a rich domain model is not free. It adds structure, friction, and rigidity. It pushes logic deeper into the core. It can make change harder. And in larger teams, it can become just as messy as procedural code, only with more sophisticated vocabulary.

So the real question is not, “Should we use an anemic or rich domain model?” The better question is, “Where does the business complexity justify the extra discipline?” That is where pragmatic design lives.

The Anemic Model: Simple, Familiar, and Often Good Enough

Most APIs I have seen, built, reviewed, or inherited use domain classes that look something like this:

public class Course
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public CourseStatus Status { get; set; }
    public DateTime? PublishedOn { get; set; }
    public decimal Price { get; set; }
}

This is the classic anemic model. The object has data, but no real behavior. It represents the shape of the data, usually very close to how it is stored in the database.

Then the business logic lives somewhere else, usually in a handler, service, validator, or workflow:

public sealed class PublishCourseHandler
{
    private readonly AppDbContext _dbContext;
    private readonly IDateTimeProvider _clock;

    public PublishCourseHandler(
        AppDbContext dbContext,
        IDateTimeProvider clock)
    {
        _dbContext = dbContext;
        _clock = clock;
    }

    public async Task Handle(
        PublishCourseCommand command,
        CancellationToken cancellationToken)
    {
        var course = await _dbContext.Courses
            .FirstOrDefaultAsync(x => x.Id == command.CourseId, cancellationToken);

        if (course is null)
        {
            throw new InvalidOperationException("Course not found.");
        }

        if (course.Status != CourseStatus.Draft)
        {
            throw new InvalidOperationException("Only draft courses can be published.");
        }

        course.Status = CourseStatus.Published;
        course.PublishedOn = _clock.UtcNow;

        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

There is nothing inherently wrong with this. It is readable, direct, and easy for most developers to understand. It maps cleanly to HTTP endpoints, database tables, DTOs, and EF Core. For many systems, especially CRUD-heavy internal applications, this is perfectly reasonable.

Not every Customer, Product, Course, Warehouse, or Address needs to become a miniature fortress of encapsulated business behavior. Sometimes data is just data. And that is where some DDD conversations lose people, because when the business rules are simple, forcing a rich model can feel like ceremony pretending to be architecture.

The Rich Model: The Object Protects Itself

A rich domain model takes a different view. Instead of allowing any caller to freely mutate state, the object controls how it changes.

For example:

public sealed class Course
{
    private readonly List<Lesson> _lessons = [];

    private Course(string title, decimal price)
    {
        Title = title;
        Price = price;
        Status = CourseStatus.Draft;
    }

    public int Id { get; private set; }
    public string Title { get; private set; }
    public decimal Price { get; private set; }
    public CourseStatus Status { get; private set; }
    public DateTime? PublishedOn { get; private set; }

    public IReadOnlyCollection<Lesson> Lessons => _lessons.AsReadOnly();

    public static Course Create(string title, decimal price)
    {
        if (string.IsNullOrWhiteSpace(title))
        {
            throw new ArgumentException("Course title is required.", nameof(title));
        }

        if (price < 0)
        {
            throw new ArgumentException("Course price cannot be negative.", nameof(price));
        }

        return new Course(title.Trim(), price);
    }

    public void Publish(DateTime utcNow)
    {
        if (Status != CourseStatus.Draft)
        {
            throw new InvalidOperationException("Only draft courses can be published.");
        }

        if (_lessons.Count == 0)
        {
            throw new InvalidOperationException("A course must have at least one lesson before publishing.");
        }

        Status = CourseStatus.Published;
        PublishedOn = utcNow;
    }

    public void RemoveLesson(int lessonId)
    {
        if (Status == CourseStatus.Published)
        {
            throw new InvalidOperationException("Published courses cannot remove lessons.");
        }

        var lesson = _lessons.FirstOrDefault(x => x.Id == lessonId);

        if (lesson is null)
        {
            throw new InvalidOperationException("Lesson not found.");
        }

        _lessons.Remove(lesson);
    }
}

This design is more protective. A course cannot be created without a title. A course cannot be published from the wrong state. A course cannot be published without lessons. A published course cannot have lessons removed through the public API of the object.

That is the appeal. The model is no longer just a bag of properties. It now makes promises. If you are holding a Course, you can trust more about it. That is powerful.

The handler also becomes simpler:

public sealed class PublishCourseHandler
{
    private readonly AppDbContext _dbContext;
    private readonly IDateTimeProvider _clock;

    public PublishCourseHandler(
        AppDbContext dbContext,
        IDateTimeProvider clock)
    {
        _dbContext = dbContext;
        _clock = clock;
    }

    public async Task Handle(
        PublishCourseCommand command,
        CancellationToken cancellationToken)
    {
        var course = await _dbContext.Courses
            .Include(x => x.Lessons)
            .FirstOrDefaultAsync(x => x.Id == command.CourseId, cancellationToken);

        if (course is null)
        {
            throw new InvalidOperationException("Course not found.");
        }

        course.Publish(_clock.UtcNow);

        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

The handler still owns the use case. It loads the course from the DbContext, calls the behavior, and saves the result. But the course owns the rule about whether it can be published.

The Real Issue Is Not Methods, It Is Ownership

A lot of the debate gets stuck on whether domain objects should have methods. That is the wrong framing. A method on an entity is not automatically better than a method in a handler or service.

This is not necessarily bad code:

public sealed class RefundTaxHandler
{
    private readonly AppDbContext _dbContext;
    private readonly ITaxCalculator _taxCalculator;

    public RefundTaxHandler(
        AppDbContext dbContext,
        ITaxCalculator taxCalculator)
    {
        _dbContext = dbContext;
        _taxCalculator = taxCalculator;
    }

    public async Task Handle(
        RefundTaxCommand command,
        CancellationToken cancellationToken)
    {
        var order = await _dbContext.Orders
            .FirstOrDefaultAsync(x => x.Id == command.OrderId, cancellationToken);

        if (order is null)
        {
            throw new InvalidOperationException("Order not found.");
        }

        if (order.Status != OrderStatus.Completed)
        {
            throw new InvalidOperationException("Only completed orders can be refunded.");
        }

        var taxRefund = _taxCalculator.CalculateRefund(order);

        order.TaxRefunded = taxRefund;
        order.Status = OrderStatus.TaxRefunded;

        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

That may be perfectly fine. The real question is this: can another caller bypass the rule?

If some other handler, import job, admin tool, or background process can do this:

order.TaxRefunded = 50.00m;
order.Status = OrderStatus.TaxRefunded;

without checking whether the order was actually eligible for a tax refund, then the rule is not really owned anywhere. It is just being remembered in one path. That is where anemic models become risky, not because they have no methods, but because they make invalid state easy.

Invariants: The Rule That Must Always Be True

This is where the word “invariant” matters. An invariant is a rule that must always hold true while the object exists.

Examples include:

A course must have a title.
A published course must have at least one lesson.
An order cannot move from Canceled back to Paid.
A shipment marked Delivered cannot be edited like a draft.
A refund cannot exceed the captured payment amount.

These are not just input validation rules. They are consistency rules. And if a rule protects the consistency of an object, there is a strong argument that the object should help enforce it.

That does not mean every rule belongs inside the entity. It means the entity should not allow itself to be placed into a state that violates its own core rules. That distinction matters.

Validators Are Useful, But They Are Not Invariants

A common response is, “Can’t we just use attributes or FluentValidation?” Yes, and we should. But they solve a different problem.

For example:

public sealed class CreateCourseRequest
{
    [Required]
    public string? Title { get; set; }

    [Range(0, 9999)]
    public decimal Price { get; set; }
}

//Or with FluentValidation

public sealed class CreateCourseCommandValidator 
    : AbstractValidator<CreateCourseCommand>
{
    public CreateCourseCommandValidator()
    {
        RuleFor(x => x.Title).NotEmpty();
        RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
    }
}

This is useful. It protects the boundary of the system. It stops bad requests early. It gives clean feedback to the caller. But it does not protect the domain object.

This can still happen if the entity allows it:

var course = new Course
{
    Title = "",
    Price = -50
};

Attributes are metadata. Validators are checkpoints. Invariants are guarantees. That is the difference.

A validator says, “Before this command runs, this input should be valid.” An invariant says, “This object cannot exist in an invalid state.” Those are related, but they are not the same thing.

The Cost of Rich Models: Rigidity Is the Point, and the Problem

The benefit of a rich model is also its downside. It is more rigid. It says you cannot just change this property, you must go through this method, you must follow this transition, and you must satisfy this rule.

That rigidity is valuable when the rules matter. But it can be painful when the system is still evolving, when the domain is not well understood, or when the team needs to move quickly.

A rich model can also become messy if the team starts putting everything into entities:

Validation
Calculations
Workflow decisions
Authorization checks
External service calls
Persistence assumptions
Notification triggers
Cross-aggregate coordination

That is not a rich domain model anymore. That is a god object wearing a DDD badge.

And on larger teams, this can become harder to keep clean than an anemic model. Different developers may disagree about where logic belongs. Entities grow. Methods become bloated. “Pure domain logic” starts quietly depending on infrastructure concepts.

So yes, rich domain modeling can absolutely become over-engineered. Better code is not always more pragmatic code.

The False Choice: Anemic vs. Rich Everywhere

The biggest mistake is treating this as a binary decision.

You do not have to choose between:

All entities are dumb database records.

and

Every entity is a carefully modeled domain aggregate with factories, value objects, private constructors, domain events, and protected collections.

That is not how real systems need to work. A more pragmatic approach is selective richness. Keep simple things simple. Make important things protective.

For example, this may be fine as an anemic model:

public class CustomerAddress
{
    public int Id { get; set; }
    public string AddressLine1 { get; set; } = string.Empty;
    public string? AddressLine2 { get; set; }
    public string City { get; set; } = string.Empty;
    public string State { get; set; } = string.Empty;
    public string PostalCode { get; set; } = string.Empty;
}

If the rules are basic, validators and database constraints may be enough.

But this probably deserves more protection:

public sealed class Order
{
    public OrderStatus Status { get; private set; }
    public decimal CapturedAmount { get; private set; }
    public decimal RefundedAmount { get; private set; }

    public void Refund(decimal amount)
    {
        if (amount <= 0)
        {
            throw new InvalidOperationException("Refund amount must be greater than zero.");
        }

        if (Status != OrderStatus.Paid)
        {
            throw new InvalidOperationException("Only paid orders can be refunded.");
        }

        if (RefundedAmount + amount > CapturedAmount)
        {
            throw new InvalidOperationException("Refund cannot exceed captured amount.");
        }

        RefundedAmount += amount;

        if (RefundedAmount == CapturedAmount)
        {
            Status = OrderStatus.Refunded;
        }
    }
}

That rule is important. It protects money. It protects state. It should not depend on every handler remembering to write the same if statements correctly.

A Practical Rule of Thumb

Here is the rule I like: start anemic, promote behavior when the rule earns it.

That means you do not begin every API with a full tactical DDD model. You begin with simple entities, DTOs, validators, handlers, direct DbContext usage, and database constraints. Then you watch for pressure.

A rule may deserve to move into the domain model when it is repeated in multiple handlers, protects an important state transition, prevents financial, operational, or security issues, is easy for another caller to bypass, describes what the object is allowed to become, or is painful to test because the logic is scattered.

That is the moment to promote the behavior. Not because a book said so, but because the code is telling you the rule matters.

Handlers Should Orchestrate, Not Become Junk Drawers

In many anemic systems, handlers slowly become the place where everything happens. They load data, validate state, calculate values, call services, change properties, send notifications, save records, and publish events.

That can be fine for a while. But eventually, handlers become procedural transaction scripts with a lot of duplicated business rules.

A cleaner middle ground looks like this:

public sealed class PublishCourseHandler
{
    private readonly AppDbContext _dbContext;
    private readonly IDateTimeProvider _clock;

    public PublishCourseHandler(
        AppDbContext dbContext,
        IDateTimeProvider clock)
    {
        _dbContext = dbContext;
        _clock = clock;
    }

    public async Task Handle(
        PublishCourseCommand command,
        CancellationToken cancellationToken)
    {
        var course = await _dbContext.Courses
            .Include(x => x.Lessons)
            .FirstOrDefaultAsync(x => x.Id == command.CourseId, cancellationToken);

        if (course is null)
        {
            throw new InvalidOperationException("Course not found.");
        }

        course.Publish(_clock.UtcNow);

        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

The handler still owns orchestration. It gets the course from the DbContext, calls the domain behavior, and saves the result. But the course owns the rule about whether it can be published.

That is a good separation. The handler coordinates the use case. The entity protects its own consistency.

Do Not Put Everything in the Entity

This is equally important. Some logic does not belong inside an entity.

For example:

Calling a payment provider
Calling a tax API
Checking the current user's permissions
Sending an email
Writing audit logs
Coordinating multiple aggregates
Running a long workflow
Deciding which EF query to execute

That logic belongs in application services, handlers, policies, workflows, or infrastructure services.

A rich domain model does not mean entities do everything. It means entities protect the rules that are naturally theirs.

For example, this belongs on the entity:

An order cannot be canceled after it has shipped.

This probably does not:

Call the shipping provider, cancel the label, notify the warehouse, refund the customer, and send the cancellation email.

The first is a state rule. The second is a workflow. Different problem, different place.

The Architecture I Actually Like

For most .NET APIs, I prefer this layered approach:

Request DTOs validate external contract shape.
Command validators validate use-case input before the handler runs.
Handlers own the vertical slice, query through DbContext, orchestrate the use case, call services, and save changes.
Domain entities protect important state transitions and consistency rules.
Domain services or policies handle business rules that do not naturally belong to one entity.
Database constraints provide the final safety net.

That gives you discipline without turning the whole codebase into a DDD museum.

For example:

public sealed class CancelOrderHandler
{
    private readonly AppDbContext _dbContext;
    private readonly IAuthorizationService _authorizationService;
    private readonly IPaymentService _paymentService;
    private readonly INotificationService _notificationService;
    private readonly IDateTimeProvider _clock;

    public CancelOrderHandler(
        AppDbContext dbContext,
        IAuthorizationService authorizationService,
        IPaymentService paymentService,
        INotificationService notificationService,
        IDateTimeProvider clock)
    {
        _dbContext = dbContext;
        _authorizationService = authorizationService;
        _paymentService = paymentService;
        _notificationService = notificationService;
        _clock = clock;
    }

    public async Task Handle(
        CancelOrderCommand command,
        CancellationToken cancellationToken)
    {
        var order = await _dbContext.Orders
            .FirstOrDefaultAsync(x => x.Id == command.OrderId, cancellationToken);

        if (order is null)
        {
            throw new InvalidOperationException("Order not found.");
        }

        await _authorizationService.EnsureCanCancelAsync(order, cancellationToken);

        order.Cancel(command.Reason, _clock.UtcNow);

        await _paymentService.VoidOrRefundAsync(order, cancellationToken);

        await _notificationService.SendOrderCanceledAsync(order, cancellationToken);

        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

And the entity handles only what belongs to it:

public void Cancel(string reason, DateTime canceledOn)
{
    if (string.IsNullOrWhiteSpace(reason))
    {
        throw new InvalidOperationException("Cancellation reason is required.");
    }

    if (Status == OrderStatus.Shipped)
    {
        throw new InvalidOperationException("Shipped orders cannot be canceled.");
    }

    if (Status == OrderStatus.Canceled)
    {
        return;
    }

    Status = OrderStatus.Canceled;
    CancellationReason = reason.Trim();
    CanceledOn = canceledOn;
}

That is not heavy. That is not academic. That is just good boundaries.

My Position: Anemic Is Fine Until It Is Not

I do not think anemic domain models are automatically bad. In fact, they are often the most pragmatic starting point.

They are easy to understand, work well with EF Core, reduce ceremony, are familiar to most teams, and keep delivery moving. But I also do not think anemic models should be defended blindly.

When business rules start repeating, drifting, or being bypassed, the model is probably too passive. That is when richer behavior starts paying for itself.

The goal is not to prove that one style is always superior. The goal is to put the right amount of design pressure in the right place.

Too little structure, and your rules drift. Too much structure, and your system becomes rigid before the business has even finished changing its mind.

Good architecture lives between those extremes.

The Punchline

An anemic model gives you flexibility. A rich model gives you trust.

Flexibility helps you move fast. Trust helps you move safely.

The job of the architect is not to pick one forever. The job is to know when the tradeoff has changed.

Start simple. Watch where the rules repeat. Protect the state that matters. And when the model finally needs to speak, give it something useful to say.

May 15, 2026 0 comment
0 FacebookTwitterPinterestEmail

Building Software, Teams, and Lasting Value

Building software applications from code to cloud the right way is my passion. Sharing knowledge with my clients and peers is my joy.

Recent Posts

  • Anemic vs. Rich Domain Models: When Better Code Becomes Too Much Code

    May 15, 2026
  • AI Agents in 2026, The Real Question Is Not What Can We Build, It Is What Should We Build

    May 1, 2026
  • Data Science: Food Hub Data Analysis

    February 21, 2025
  • Breaking the Code

    November 16, 2023
  • Navigating the AI Landscape

    August 10, 2023

Categories

  • Development Posts (9)
    • Artificial Intelligence (3)
    • Cloud Architecture (1)
    • Serverless (2)
    • Software Architecture (1)
    • Strategy (1)
    • Success Stories (1)
  • Development Videos (5)
    • DevOps (2)
    • Microservices (3)

@2022 - All Right Reserved.


Back To Top
Mario Mamalis
  • About Me
  • Development Posts
  • Development Videos

Loading Comments...