📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Rich domain model or anemic domain model: what is the difference, what problems does an anemic model cause, and how would you move a .NET codebase towards a richer model without over-engineering it?

Design judgement on rich versus anemic domain models: behaviour with data, invariants, transaction scripts, when anemic is acceptable, and incremental refactoring with EF Core.

Senior Architecture Advanced anemic-domain-model csharp design domain-driven-design ef-core oop refactoring rich-domain-model

🎯 Key points

  • Anemic models are property bags with rules in services; rich models put behaviour and invariants in entities
  • Anemic models duplicate rules, allow invalid states and need heavy mocking to test rules
  • Rich entities use private setters, business-named methods, encapsulated collections and value objects
  • Application services stay thin: load, call domain methods, save, publish events
  • Anemic models are acceptable for simple CRUD, reporting and CQRS read models
  • EF Core supports rich models: private setters and constructors, backing fields, owned/complex types, value converters
  • Refactor incrementally: private setters first, move one rule at a time, guard with characterisation tests
  • Avoid over-engineering: no aggregates and events where there are no rules

⏱ 30-second answer

In an anemic domain model, entities are property bags with public getters and setters, and all business rules live in service classes that read and set those properties. In a rich domain model, entities own their behaviour: state is private, changes happen through methods like order.Pay(amount) that enforce invariants, and services only orchestrate (load, call, save). Anemic models scatter and duplicate rules, let any code put objects into invalid states, and are hard to test without a database. They are acceptable for simple CRUD or reporting with few rules. Move towards a richer model incrementally: make setters private, move one rule at a time into the entity, protect collections, and use value objects, guided by where bugs actually occur.

🗣 Full answer (senior)

How I would answer this in an interview

In an anemic domain model, entities are bags of properties with getters and setters, and all business logic lives in services that manipulate them. In a rich domain model, entities and value objects own their behaviour and invariants: you call order.Ship(), and the order decides whether that's allowed. I'd explain the problems anemia causes and how I'd move a .NET codebase towards a richer model incrementally, without turning it into a DDD ceremony.

1. The difference

Anemic:

public class Order { public OrderStatus Status { get; set; } public List<OrderLine> Lines { get; set; } = []; public decimal Total { get; set; } public DateTime? ShippedAt { get; set; } }

public class OrderService
{
    public void Ship(Order order)
    {
        if (order.Status != OrderStatus.Paid) throw new InvalidOperationException();
        order.Status = OrderStatus.Shipped;
        order.ShippedAt = DateTime.UtcNow;
    }
}

Rich:

public sealed class Order
{
    private readonly List<OrderLine> _lines = [];
    public OrderStatus Status { get; private set; }
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
    public Money Total => _lines.Aggregate(Money.Zero(Currency), (t, l) => t + l.Amount);
    public DateTimeOffset? ShippedAt { get; private set; }

    public void AddLine(ProductId product, int quantity, Money price)
    {
        if (Status != OrderStatus.Draft) throw new OrderLockedException(Id);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        _lines.Add(new OrderLine(product, quantity, price));
    }

    public void Ship(DateTimeOffset at)
    {
        if (Status != OrderStatus.Paid) throw new InvalidOrderStateException(Id, Status, OrderStatus.Shipped);
        Status = OrderStatus.Shipped;
        ShippedAt = at;
        AddDomainEvent(new OrderShipped(Id, at));
    }
}

2. Problems an anemic model causes

  • Duplicated and inconsistent rules: the "can only ship paid orders" check appears in the API, a background job and an admin tool — and one of them forgets it.
  • Invalid states are easy: any code can set Status = Shipped without ShippedAt, or modify lines after shipping; totals drift from lines.
  • Rules are hard to find: logic is spread across OrderService, OrderHelper, OrderValidator, controllers.
  • Testing is harder: you test services with mocks instead of testing plain objects; rules depend on orchestration.
  • God services: OrderService grows to thousands of lines with tangled responsibilities.
  • Primitive obsession: decimal for money, string for emails — no type enforces validity.

Martin Fowler called the anemic model an anti-pattern when you have real business rules. For simple CRUD with little logic, an anemic model plus straightforward services is fine — and that's the over-engineering caveat.

3. How I'd move a codebase toward a richer model (incrementally)

  1. Find where it hurts: bugs and duplication around specific concepts (order state, subscription billing, inventory). Start there, not everywhere.
  2. Add characterisation tests around current behaviour of the services you'll change.
  3. Introduce value objects for primitives with rules: Money, EmailAddress, Abn, DateRange — records that validate on creation. Low risk, high value.
  4. Move one rule at a time into the entity: create order.Ship(at), make the service call it, then make Status and ShippedAt private set. The compiler finds every place that set them directly — fix each to use the method.
  5. Encapsulate collections: private List<T> + IReadOnlyList<T> + AddLine/RemoveLine, with EF Core backing-field mapping.
  6. Keep services as orchestrators: application services load aggregates, call domain methods, save, publish events. Domain services only for logic spanning aggregates (e.g. pricing across products).
  7. Use domain events for side effects (send email when shipped) instead of services doing everything inline.
  8. Stop when it stops paying off: simple reference data and CRUD screens can stay anemic.

EF Core supports this well: private setters, backing fields (HasField), private parameterless constructors, owned/complex types for value objects.

4. Avoiding over-engineering

  • No need for repositories per entity, CQRS, event sourcing or a full DDD tactical toolkit to get the benefit.
  • Don't create aggregates for everything; reference data (Country, Category) can be simple.
  • Don't put infrastructure (email, HTTP, database) inside entities — keep them pure; pass values or use domain events.
  • Prefer exceptions or result types consistently for rule violations.

5. A production example (how I'd tell it)

[In my case] a subscription billing system had status changes made from [six] places; [an incident] billed cancelled customers because a support tool set Status = Cancelled without setting CancelledAt, which the billing job used. I added Cancel(effectiveDate, reason) and Reactivate() methods to Subscription, made the setters private, fixed the [N] compile errors by routing callers through the methods, and moved the "bill only active subscriptions" rule into a Subscription.IsBillableOn(date) method with unit tests. Billing incidents of that type stopped, and new rules (pausing) were added in one place.

🔍 What the interviewer is checking

Checks design maturity: understanding the trade-off and a practical migration path.

Green flags: clear definitions; concrete problems of anemic models (duplication, invalid states, testing); knows when anemic is fine (CRUD, read models); EF Core features that support rich models; incremental refactoring plan; warns against over-engineering.

Red flags: "always DDD" or "rich models are academic"; injects repositories into entities; SetX methods presented as rich behaviour; big-bang rewrite plans.

📚 Learn in depth

1. Domain-driven design building blocks (just enough)

  • Entity: identity + lifecycle; behaviour and invariants (Order).
  • Value object: immutable, compared by value, validates itself (Money).
  • Aggregate: a consistency boundary with a root entity; all changes go through the root; other aggregates referenced by ID.
  • Domain event: an immutable record of something that happened (OrderShipped), raised by the aggregate and dispatched after saving.
  • Domain service: stateless logic that doesn't belong to one entity (e.g. pricing involving multiple aggregates).
  • Application service / handler: orchestrates use cases: load, call domain methods, persist, publish.

A rich model uses these selectively.

2. Tell, Don't Ask

Anemic code asks for data and decides elsewhere:

if (order.Status == OrderStatus.Paid && order.Lines.Count > 0) { order.Status = OrderStatus.Shipped; }

Rich code tells the object what to do and lets it decide:

order.Ship(clock.GetUtcNow());

This keeps decisions next to the data they depend on (information expert principle, GRASP).

3. EF Core support for rich models

builder.Property(o => o.Status).HasConversion<string>();
builder.Navigation(o => o.Lines).HasField("_lines").UsePropertyAccessMode(PropertyAccessMode.Field);
builder.ComplexProperty(o => o.ShippingAddress);
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).HasConversion(id => id.Value, v => new OrderId(v));
  • Private parameterless constructor for materialisation.
  • Private setters and backing fields.
  • Value conversions for strongly typed IDs and value objects.
  • SaveChanges interceptor to dispatch domain events after commit (or an outbox for reliability).

4. Testing a rich model

Domain tests become plain unit tests — no mocks, no database:

[Fact]
public void Cannot_ship_unpaid_order()
{
    var order = OrderMother.Draft();
    var act = () => order.Ship(DateTimeOffset.UtcNow);
    act.Should().Throw<InvalidOrderStateException>();
}

Application-layer tests cover orchestration (with fakes or real infrastructure via Testcontainers).

5. Transaction script vs domain model

Fowler's patterns: Transaction Script (procedural service per use case) is simple and fine for little logic; Domain Model pays off as rules grow. Many systems mix them: rich model in the complex core domain (billing, pricing), transaction scripts in simple supporting areas (admin CRUD). DDD calls this distinguishing core from supporting and generic subdomains.

6. Common objections and answers

  • "Entities shouldn't depend on time/services." → pass values (DateTimeOffset at) or policies as method parameters; keep I/O out.
  • "EF Core needs public setters." → it doesn't; use private setters/backing fields.
  • "Serialisation needs public properties." → serialise DTOs, not entities.
  • "It's more code." → less duplicated logic overall; tests become simpler.

Try it yourself

  1. Pick one property with a public setter changed from multiple places; make the setter private and add a method; fix the compile errors.
  2. Introduce a Money value object and replace decimal in one aggregate.
  3. Map a private collection with EF Core backing fields.
  4. Write domain unit tests for the new methods without any mocks.

💻 Code

public sealed class Subscription
{
    private readonly List<DomainEvent> _events = [];
    private Subscription() { }                                  // EF Core

    public Guid Id { get; private set; }
    public SubscriptionStatus Status { get; private set; }
    public DateOnly PaidUntil { get; private set; }
    public Money Price { get; private set; } = default!;
    public IReadOnlyList<DomainEvent> Events => _events;

    public static Subscription Start(Money price, DateOnly today) =>
        new() { Id = Guid.CreateVersion7(), Status = SubscriptionStatus.Active, Price = price, PaidUntil = today.AddMonths(1) };

    public void Renew(TimeProvider clock)
    {
        if (Status == SubscriptionStatus.Cancelled)
            throw new DomainException("A cancelled subscription cannot be renewed");
        var today = DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime);
        if (PaidUntil > today.AddDays(7)) return;                // not due yet
        PaidUntil = PaidUntil.AddMonths(1);
        _events.Add(new SubscriptionRenewed(Id, Price, PaidUntil));
    }

    public void Cancel()
    {
        if (Status == SubscriptionStatus.Cancelled) return;      // idempotent
        Status = SubscriptionStatus.Cancelled;
        _events.Add(new SubscriptionCancelled(Id));
    }
}

// Thin application service: orchestration only
public sealed class RenewSubscriptionsJob(AppDb db, TimeProvider clock, IPaymentGateway payments)
{
    public async Task RunAsync(CancellationToken ct)
    {
        var due = await db.Subscriptions
            .Where(s => s.Status == SubscriptionStatus.Active)
            .ToListAsync(ct);
        foreach (var s in due) s.Renew(clock);                   // the rule lives in ONE place
        await db.SaveChangesAsync(ct);                           // events dispatched by an interceptor
    }
}

// Unit test: no database, no mocks except time
[Fact]
public void Cancelled_subscription_cannot_be_renewed()
{
    var s = Subscription.Start(new Money(20, "AUD"), new DateOnly(2026, 1, 1));
    s.Cancel();
    Assert.Throws<DomainException>(() => s.Renew(new FakeTimeProvider(new DateTimeOffset(2026, 1, 30, 0, 0, 0, TimeSpan.Zero))));
}

⚠ Common mistakes

1. Business rules scattered in services

What happens: checks and state changes spread across controllers, services and jobs. Why it is wrong: duplicated, inconsistent rules and invalid states. Do instead: move rules into entity methods that enforce invariants.

2. Public setters on entities

What happens: any code can change any property. Why it is wrong: invariants can't be enforced. Do instead: private setters and intention-revealing methods.

3. Big-bang DDD rewrites

What happens: rewriting the whole model with full DDD patterns at once. Why it is wrong: high risk, long delays and over-engineering. Do instead: improve incrementally where rules and bugs concentrate.

4. Infrastructure inside entities

What happens: entities send emails or call repositories. Why it is wrong: couples the domain to infrastructure; hard to test. Do instead: keep entities pure; use domain events and application services.

5. Applying a rich model to simple CRUD

What happens: reference data gets aggregates, events and factories. Why it is wrong: ceremony with no rules to protect. Do instead: keep simple areas simple.

6. Primitive obsession

What happens: money as decimal, emails as string, IDs as Guid everywhere. Why it is wrong: validation is duplicated and mix-ups compile. Do instead: value objects and strongly typed IDs.

7. Exposing mutable collections

What happens: public List<OrderLine> Lines { get; set; }. Why it is wrong: callers bypass rules for adding/removing lines. Do instead: private collections with read-only views and methods.

8. Using entities as DTOs

What happens: rich entities serialised to APIs. Why it is wrong: exposes internals and couples contracts to the domain. Do instead: map to response DTOs or use projections.

🏛 Senior insight: trade-offs

Seniors match the model's richness to the domain's complexity and refactor towards it incrementally.

Area Senior choice
Core domain with many rules (billing, orders, claims) Rich entities, value objects, events
Supporting CRUD (reference data, settings) Simple classes or records
Read side / reports DTO-shaped projections (intentionally anemic)
Legacy anemic code with bugs Private setters first, then move rules one at a time
Cross-aggregate processes Application services or domain services, still thin

They measure success by fewer rule-related defects and faster unit tests, not by how many DDD patterns appear in the code.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate keeps request data out of entities Why: Checks domain model versus DTO separation
    How to answer

    A domain model represents business concepts with behaviour and invariants (an Order that can be paid, cancelled, and refuses invalid changes). A DTO (data transfer object) is a flat, behaviour-free contract for moving data across a boundary: an HTTP request or response, a message, a query result. Keep them separate so that the API contract and the domain can change independently, so clients cannot over-post fields they must not set (IsAdmin, Status), and so internal data does not leak in responses. Map with explicit code, source-generated mappers (Mapperly) or EF Core projections straight to DTOs for reads. Sharing one type is acceptable only for simple CRUD with no rules, and even then be careful at public boundaries.

  2. When: Candidate mentions private setters and invariants Why: Checks encapsulation fundamentals
    How to answer

    Encapsulation means an object owns its state and only lets it change through behaviour that keeps it valid. In C# you keep fields private, expose read access through properties (often get-only or private set), and offer methods like Withdraw(amount) that check rules before changing state, instead of a public setter on Balance. Collections are exposed as IReadOnlyList<T> with Add... methods. The benefit is that invariants ("balance never goes below the overdraft limit") are enforced in one place, so callers cannot put the object into an invalid state, and the internals can change without breaking callers.

🛠 Real-world problems: how you handle them

Cancelled subscriptions charged because a rule was duplicated
Situation
A subscription billing system had an anemic `Subscription` entity; renewal rules lived in [four] services and a nightly job.
Signals
Customer complaints and refund requests: about [200] cancelled customers were charged for another month after a job change.
Investigation
Searched for writes to `PaidUntil` and `Status`; found [five] places implementing renewal, and the nightly job's copy lacked the cancelled-status check.
Resolution
Made `Subscription` setters private, added `Renew(TimeProvider)` and `Cancel()` with the rules, and replaced all copies with calls. Issued refunds and notified affected customers.
Prevention
Unit tests on entity rules; architecture test forbids public setters on entities in the billing module; code review checklist asks where each new rule lives.

How to tell it (STAR)

**Situation:** [about 200] cancelled customers were charged because one of [five] copies of the renewal rule missed a check. **Task:** fix the incident and stop rules from drifting apart. **Action:** I moved renewal and cancellation into the `Subscription` entity with private setters, replaced every copy with a method call, added fast unit tests and an architecture rule against public setters on billing entities. **Result:** refunds were processed within [two days], the rule now lives in one place, and billing defects in that area dropped sharply over the next quarter.

↩ Usually asked after

More in Design Judgment

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me