Design judgement on rich versus anemic domain models: behaviour with data, invariants, transaction scripts, when anemic is acceptable, and incremental refactoring with EF Core.
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.
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.
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));
}
}
Status = Shipped without ShippedAt, or modify lines after shipping; totals drift from lines.OrderService, OrderHelper, OrderValidator, controllers.OrderService grows to thousands of lines with tangled responsibilities.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.
Money, EmailAddress, Abn, DateRange — records that validate on creation. Low risk, high value.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.List<T> + IReadOnlyList<T> + AddLine/RemoveLine, with EF Core backing-field mapping.EF Core supports this well: private setters, backing fields (HasField), private parameterless constructors, owned/complex types for value objects.
Country, Category) can be simple.[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.
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.
Order).Money).OrderShipped), raised by the aggregate and dispatched after saving.A rich model uses these selectively.
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).
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));
SaveChanges interceptor to dispatch domain events after commit (or an outbox for reliability).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).
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.
DateTimeOffset at) or policies as method parameters; keep I/O out.Money value object and replace decimal in one aggregate.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))));
}
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Last updated 30 Oct 2026.