Skip to content
The Anemic Domain Model Antidote: Rich Domain Objects with Value Objects, Specifications, and Domain Services in Modern C#

The Anemic Domain Model Antidote: Rich Domain Objects with Value Objects, Specifications, and Domain Services in Modern C#

1 The Anemic Domain Model Problem in Modern .NET

The “anemic domain model” is one of the most persistent architectural anti-patterns in enterprise .NET applications. It looks fine in the beginning—controllers and services orchestrate workflows, entities act as data carriers, and everything seems testable and “clean.” But once business complexity grows, the model collapses under its own weight. Rules scatter, invariants leak, and correctness becomes fragile.

Let’s break down what “anemic” actually means, why it still thrives, and how we can counter it using a rich domain model powered by value objects, specifications, and domain services in modern C#.

1.1 What “Anemic” Really Means and Why It Persists in Enterprise Teams

The term “Anemic Domain Model” was coined by Martin Fowler to describe an object model where entities hold data but no behavior. In such systems, the “domain layer” is reduced to a collection of DTOs (Data Transfer Objects) with properties and public setters. Business logic is offloaded into “service” classes or even controller actions.

A typical anemic entity might look like this:

public class Order
{
    public Guid Id { get; set; }
    public Guid CustomerId { get; set; }
    public decimal Total { get; set; }
    public OrderStatus Status { get; set; }
}

This is not a domain model—it’s a data container. The real behavior (like applying discounts, checking credit limits, or confirming fulfillment) lives somewhere else, often repeated or duplicated.

So why do teams keep doing this?

  • ORM-driven design: When using EF Core or NHibernate, developers often start from database tables instead of domain concepts. The database schema becomes the model.
  • Layered architecture inertia: Classic “3-tier” templates separate data access, business logic, and UI, encouraging “service + repository + entity” patterns where entities are passive.
  • Fear of complexity: Developers equate domain-driven design (DDD) with overengineering. They avoid rich domain models assuming they slow development.
  • Lack of shared vocabulary: Without a ubiquitous language or domain collaboration, there’s no natural “home” for domain behavior, so it lands in “services.”

The result: systems that look modular but lack encapsulation. The domain is thin and scattered across layers.

1.2 Symptoms in Typical ASP.NET Core Codebases

You can spot an anemic model quickly by observing the following code smells:

1.2.1 Fat Controllers and Application Services

Business logic ends up in controllers or application services, performing validation, calling repositories, and computing state transitions.

// Incorrect: Logic leaking into controller
[HttpPost("orders/{id}/confirm")]
public async Task<IActionResult> Confirm(Guid id)
{
    var order = await _orderRepo.GetByIdAsync(id);
    if (order.Status != OrderStatus.Pending)
        return BadRequest("Cannot confirm non-pending order");

    order.Status = OrderStatus.Confirmed;
    order.ConfirmedAt = DateTime.UtcNow;
    await _orderRepo.SaveAsync(order);

    return Ok();
}

The controller performs domain validation, changes entity state, and persists it. Any rule change forces controller edits—violating separation of concerns.

1.2.2 Dumb Entities

Entities expose public setters, no validation, and no intention-revealing methods:

public class Customer
{
    public Guid Id { get; set; }
    public decimal CreditLimit { get; set; }
    public decimal CurrentBalance { get; set; }
}

There’s nothing preventing an external component from setting an invalid balance.

1.2.3 Leaky Invariants and Duplicate Rules

Rules are repeated across layers. For example, checking credit limits might occur in:

  • The controller (for user feedback)
  • A service (for validation)
  • A stored procedure (for enforcement)

Each location evolves differently, leading to inconsistent states.

1.2.4 Bloated “God Services”

Teams often create domain services like OrderService or CustomerService that become 2,000-line files mixing orchestration, validation, and persistence logic. They are procedural scripts in disguise.

1.3 Impact on Correctness, Change Cost, Testability, and Performance

The anemic model exacts a hidden cost that compounds over time:

1.3.1 Correctness

When domain rules are not centralized, enforcing invariants becomes optional. You may validate in some paths but not others (e.g., background jobs vs. API endpoints). Data corruption and inconsistent business behavior follow.

1.3.2 Change Cost

Adding a new rule—say, “Orders over $10,000 require credit approval”—requires edits across multiple services, DTOs, and database procedures. This lack of locality makes refactoring risky.

1.3.3 Testability

Unit testing is difficult because behavior isn’t attached to domain objects. You can’t test invariants in isolation. Most tests become integration tests—slow and brittle.

1.3.4 Performance and Concurrency

When the model is procedural, developers tend to re-fetch aggregates, apply external calculations, and persist again. This increases roundtrips and introduces concurrency bugs (e.g., lost updates).

1.4 How Rich Domain Models Counter These Issues

A rich domain model treats entities and value objects as the central abstraction of business behavior. They expose intent-revealing methods that encapsulate rules and maintain internal consistency.

Here’s the same Order modeled richly:

public class Order : AggregateRoot
{
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    public Money Total => Money.From(_lines.Sum(l => l.Subtotal.Amount), Currency.USD);
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;

    public void Confirm(CreditLimit limit)
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Only pending orders can be confirmed");

        if (Total > limit.Available)
            throw new DomainException("Credit limit exceeded");

        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmed(Id));
    }
}

The model:

  • Encapsulates invariants (Total and Status checks)
  • Expresses business intent (Confirm)
  • Raises domain events (OrderConfirmed)
  • Hides mutation behind meaningful methods

This design:

  • Localizes change (modify rules in one place)
  • Reduces bugs (model can’t be in an invalid state)
  • Improves testability (you can unit test aggregates directly)
  • Supports high performance (aggregate state is consistent in memory)

1.5 When Not to Force DDD

Not every system needs rich domain models. For simple CRUD applications—like configuration dashboards, basic lookup tables, or admin panels—transaction scripts or “thin service” approaches work better.

Use DDD when:

  • Business rules are complex or evolving
  • Behavior depends on domain-specific calculations
  • Consistency boundaries matter
  • You need an expressive domain language shared with stakeholders

Avoid DDD when:

  • Entities are just data without behavior
  • Rules are static or purely infrastructural
  • You have high read/write ratios but minimal logic

The goal is appropriateness, not purity. Rich domain models are powerful tools—but only when the problem warrants them.


2 Foundations for Rich Domain Models in C# and .NET Today

To build a robust, expressive domain model, you need both conceptual clarity and modern tooling. C# and .NET have matured dramatically since the early DDD days—especially with C# 13 and .NET 9—making it easier to express intent directly in code.

2.1 Target Runtime and Language: .NET 9 and C# 13

C# 13 introduces refinements that align beautifully with DDD principles.

2.1.1 Records and Value Equality

Records give you concise syntax for value-based equality—perfect for Value Objects:

public readonly record struct Money(decimal Amount, Currency Currency);

Immutability and equality semantics are built-in, eliminating boilerplate.

2.1.2 Readonly Structs

Readonly structs are lightweight and avoid accidental mutation:

public readonly struct Percentage
{
    public decimal Value { get; }
    public Percentage(decimal value)
    {
        if (value < 0 || value > 1)
            throw new ArgumentOutOfRangeException(nameof(value));
        Value = value;
    }
}

They work well for micro-types that model quantities or rates.

2.1.3 With-Expressions and Required Members

With-expressions support fluent mutation of immutable types:

var discounted = price with { Amount = price.Amount * 0.9m };

Required members enforce invariant initialization:

public class Customer
{
    public required CustomerId Id { get; init; }
    public required string Name { get; init; }
}

2.1.4 File-Scoped Types

C# 13 allows file-scoped types, improving modularity by hiding internal helpers (guards, validators) within the same file.

2.1.5 Pattern Matching and Exhaustive Switches

Pattern matching simplifies business rule enforcement with clear intent:

return order.Status switch
{
    OrderStatus.Pending => HandlePending(order),
    OrderStatus.Confirmed => HandleConfirmed(order),
    _ => throw new InvalidOperationException("Unsupported state")
};

These language features make domain logic expressive, type-safe, and maintainable.

2.2 Architectural Posture: Vertical Slices and Aggregate Boundaries

The traditional layered architecture (UI → Service → Repository → Entity) doesn’t map cleanly to business workflows. Instead, vertical slice architecture aligns technical boundaries with use cases.

A vertical slice encapsulates:

  • Command/query handler
  • Domain model (aggregate root, value objects)
  • Infrastructure adapter (repository, persistence mapping)

Within a bounded context, define aggregate boundaries carefully:

  • Each aggregate should enforce invariants internally.
  • Cross-aggregate consistency should be eventual, not transactional.

Example:

  • Order and CreditAccount are separate aggregates.
  • Order.Confirm() raises a CreditAuthorized event consumed asynchronously by CreditAccount.

This keeps transactions small and models explicit.

2.3 Tactical DDD Refresher

Here’s a quick recap of tactical DDD building blocks and how they map to .NET constructs.

2.3.1 Entities

Objects with identity and lifecycle. Their equality is based on identity, not attributes.

  • Example: Order, Customer
  • Typically represented as class with private setters.

2.3.2 Value Objects

Immutable, equality-by-value types representing measurable or descriptive concepts.

  • Example: Money, EmailAddress, Quantity
  • Represented as record struct or readonly struct

2.3.3 Aggregates and Roots

Aggregates enforce invariants across entities and value objects. The root controls access to children.

  • Example: Order (root) manages OrderLine entities.
  • All updates occur via methods on the root.

2.3.4 Repositories

Abstractions over persistence that return aggregates. Example:

public interface IOrderRepository
{
    Task<Order?> GetAsync(OrderId id);
    Task SaveAsync(Order order);
}

2.3.5 Specifications

Encapsulated query intent (e.g., “Orders pending confirmation”) without exposing EF or SQL. Reusable and testable.

2.3.6 Domain Events

Represent something meaningful that happened within the model, e.g., OrderConfirmed, StockReserved.

2.3.7 Domain Services

When logic spans multiple aggregates or doesn’t naturally belong to one entity (e.g., pricing engine, credit policy).

Together, these form the tactical foundation for rich domain models.

2.4 Tooling and Libraries We’ll Lean On (and Why)

Modern .NET has a mature ecosystem that supports DDD principles with minimal friction.

2.4.1 EF Core 10 (Preview)

EF Core 10 introduces key improvements for aggregate-friendly persistence:

  • Owned types: map value objects directly.
  • JSON columns: store owned types efficiently in PostgreSQL/SQL Server.
  • Concurrency tokens: support optimistic concurrency for aggregates.
  • Improved raw SQL mapping for custom projections.

Alternatives like Marten (on PostgreSQL) offer document + event store semantics that align better with event-sourced aggregates.

2.4.2 Ardalis.Specification

This library by Steve Smith formalizes the Specification pattern for EF Core and other ORMs.

Example:

public class OrdersPendingSpecification : Specification<Order>
{
    public OrdersPendingSpecification()
    {
        Query.Where(o => o.Status == OrderStatus.Pending)
             .Include(o => o.Customer);
    }
}

This decouples query intent from persistence and makes tests cleaner.

2.4.3 StronglyTypedId and ValueOf

StronglyTypedId auto-generates strongly-typed wrappers for IDs:

[StronglyTypedId]
public partial struct OrderId;

No more Guid confusion between aggregates.

ValueOf provides a base for self-validating value objects:

public class EmailAddress : ValueOf<string, EmailAddress>
{
    protected override void Validate()
    {
        if (!Regex.IsMatch(Value, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"))
            throw new DomainException("Invalid email address");
    }
}

2.4.4 NodaTime and FluentValidation

NodaTime replaces DateTime with precise types:

  • Instant for absolute time
  • ZonedDateTime for business time zones
  • Duration for time spans

This eliminates bugs from ambiguous DateTimeKind handling.

FluentValidation complements domain invariants by handling input validation—ensuring invalid requests never enter the domain.

2.4.5 Optional: MediatR and Scrutor

MediatR orchestrates commands and events between layers in a decoupled manner:

public record ConfirmOrderCommand(OrderId Id) : IRequest;

Scrutor enables automatic DI registration via assembly scanning:

services.Scan(scan => scan
    .FromAssembliesOf(typeof(IOrderRepository))
    .AddClasses()
    .AsImplementedInterfaces()
    .WithScopedLifetime());

These tools don’t enforce DDD—they enable clean composition around it.


3 Value Objects First: The Antidote to Primitive Obsession

The easiest entry point to richer domain models is Value Objects (VOs). They replace ambiguous primitives (decimal, string, Guid) with meaningful types that express domain concepts and enforce invariants.

3.1 Properties of Value Objects

A good Value Object has these traits:

  • Immutability: Once created, its state never changes.
  • Value-based equality: Two instances with the same values are equal.
  • No identity: It’s defined entirely by its data.
  • Behavior-rich: It encapsulates logic related to its meaning.

For example, Money is more than a decimal. It knows how to add, subtract, and format itself.

public readonly record struct Money(decimal Amount, Currency Currency)
{
    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency)
            throw new InvalidOperationException("Cannot add different currencies");
        return new Money(a.Amount + b.Amount, a.Currency);
    }
}

3.2 Modeling Money, Quantity, Percentage, Tax Rate, Email, SKU

Each primitive in your model represents an opportunity for a Value Object.

ConceptTypical TypeBetter Type
MoneydecimalMoney
PercentagedecimalPercentage
EmailstringEmailAddress
SKUstringSku
QuantityintQuantity
Tax RatedecimalTaxRate

Example: Email Address

public sealed class EmailAddress : ValueOf<string, EmailAddress>
{
    protected override void Validate()
    {
        if (!Regex.IsMatch(Value, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"))
            throw new DomainException("Invalid email address");
    }
}

3.3 Implementing Value Equality and Invariants

For custom VOs, you can implement equality explicitly:

public sealed class Percentage : IEquatable<Percentage>
{
    public decimal Value { get; }

    public Percentage(decimal value)
    {
        if (value < 0 || value > 1)
            throw new ArgumentOutOfRangeException(nameof(value));
        Value = value;
    }

    public bool Equals(Percentage? other) => other is not null && Value == other.Value;
    public override int GetHashCode() => Value.GetHashCode();
}

By encoding invariants in the constructor, you guarantee valid state throughout the system.

3.4 Using StronglyTypedId and ValueOf

Instead of polluting aggregates with raw GUIDs or strings, use strongly typed identifiers.

[StronglyTypedId]
public partial struct OrderId;

For value objects, ValueOf reduces boilerplate while retaining domain logic.

public class Quantity : ValueOf<int, Quantity>
{
    protected override void Validate()
    {
        if (Value <= 0)
            throw new DomainException("Quantity must be positive");
    }
}

These libraries let you model richer concepts with minimal friction.

3.5 Time and Calendars with NodaTime

Business domains often require precise temporal logic—cutoffs, time zones, fulfillment windows. NodaTime offers clear semantics:

public sealed record FulfillmentWindow(ZonedDateTime Start, ZonedDateTime End)
{
    public bool Includes(Instant instant) =>
        instant >= Start.ToInstant() && instant <= End.ToInstant();
}

Instead of juggling DateTime.UtcNow and conversions, you express time intent directly.

3.6 Persistence with EF Core: Owned Types and Conversions

EF Core’s owned types map value objects elegantly.

modelBuilder.Entity<Order>()
    .OwnsOne(o => o.Total, m =>
    {
        m.Property(p => p.Amount).HasColumnName("TotalAmount");
        m.Property(p => p.Currency).HasColumnName("Currency");
    });

EF Core treats the VO as part of the aggregate—it’s persisted atomically, respecting immutability.

For more complex types (like NodaTime Instant), use value converters:

builder.Property(o => o.FulfillmentDate)
    .HasConversion(
        v => v.ToDateTimeUtc(),
        v => Instant.FromDateTimeUtc(DateTime.SpecifyKind(v, DateTimeKind.Utc)));

3.7 Testing Value Objects

Testing VOs is straightforward because they’re pure functions with no side effects.

Example: Property-Based Test with FsCheck

[Property]
public void Money_Addition_Should_Be_Associative(decimal a, decimal b)
{
    var m1 = new Money(a, Currency.USD);
    var m2 = new Money(b, Currency.USD);
    (m1 + m2).Amount.Should().Be(a + b);
}

Example: Mutation Testing

Run a mutation testing suite (e.g., Stryker.NET) to ensure your invariants actually protect against invalid states.

Example: Data-Driven Test

[Theory]
[InlineData(-1)]
[InlineData(1.5)]
public void Percentage_Should_Reject_Invalid_Range(decimal value)
{
    Action act = () => new Percentage(value);
    act.Should().Throw<ArgumentOutOfRangeException>();
}

By starting with value objects, you build a foundation of correctness. Every higher-level behavior—aggregates, services, specifications—becomes simpler because the building blocks are solid.


4 Aggregates That Actually Enforce Invariants

By now, our building blocks—value objects and expressive types—can represent domain concepts precisely. The next step is constructing aggregates that protect invariants and model real-world operations as explicit behaviors. In well-structured domains, aggregates aren’t just data structures. They are transactional boundaries of consistency and business intent.

4.1 Drawing Aggregate Boundaries: Transactional Consistency vs. Query Needs

The first design decision in aggregate modeling is defining how big the aggregate should be. The goal is to ensure all invariants within an aggregate are enforceable in a single transaction. Anything that doesn’t require immediate consistency belongs elsewhere—either another aggregate or a read model.

For example, in a B2B ordering context:

  • Order (root) owns OrderLines because line totals affect the order total—an invariant needing atomic updates.
  • CustomerCredit or Inventory do not belong in the same aggregate. Their consistency can be eventual.

This principle avoids transactional bloat. You maintain correctness without dragging unrelated data into every write operation.

Incorrect (aggregate too large):

public class Customer
{
    public Guid Id { get; private set; }
    private readonly List<Order> _orders = new();
    // Mutating orders here couples customer and order consistency.
}

Correct (bounded aggregates):

public class Customer
{
    public CustomerId Id { get; }
    public Money CreditLimit { get; private set; }
}

public class Order : AggregateRoot
{
    public CustomerId CustomerId { get; }
    private readonly List<OrderLine> _lines = new();
}

Each aggregate maintains its internal invariants but interacts via domain events (like CreditAuthorized or OrderConfirmed) to preserve overall system behavior. This design scales better and makes transactional consistency explicit.

4.2 Designing an Aggregate Root API That Models Ubiquitous Language

A rich aggregate doesn’t expose setters. Instead, it provides intent-revealing methods that align with how domain experts speak about the business.

Consider a rule: “An order can only be confirmed if credit is authorized.” Instead of setting Status and ConfirmedAt directly, expose a command that models the intent.

public class Order : AggregateRoot
{
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;
    public Money Total => Money.From(_lines.Sum(x => x.Subtotal.Amount), Currency.USD);

    public void Confirm(CreditLimit credit)
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Only pending orders can be confirmed.");

        if (Total > credit.Available)
            throw new DomainException("Insufficient credit.");

        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmed(Id, Total));
    }
}

This API reads like a business rule rather than a setter sequence. The caller doesn’t manage state transitions—it just expresses intent. This protects invariants and keeps the aggregate state valid.

From the outside, application code looks concise:

var credit = await _creditRepo.GetLimitFor(order.CustomerId);
order.Confirm(credit);
await _orderRepo.SaveAsync(order);

The aggregate ensures correctness without any procedural orchestration.

4.3 Encapsulating Collections and Child Entities; Preventing “Setters Everywhere”

Aggregates often manage child entities—like order lines, invoice items, or product attributes. The root must control mutations to preserve consistency.

Incorrect (leaky collection):

public List<OrderLine> Lines { get; set; } = new();

External code can mutate _lines arbitrarily, violating invariants.

Correct (encapsulated):

private readonly List<OrderLine> _lines = new();
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

public void AddLine(ProductId productId, Quantity quantity, Money price)
{
    if (_lines.Any(l => l.ProductId == productId))
        throw new DomainException("Duplicate product in order.");

    _lines.Add(new OrderLine(productId, quantity, price));
}

The root owns the consistency of its internal state. Consumers interact only through well-defined methods.

When you combine encapsulation with intent-revealing APIs, the domain logic becomes self-documenting and robust. You can evolve invariants without changing external code, because no one is mutating collections directly.

4.4 Enforcing Business Rules Synchronously in Methods

Business rules should execute inside aggregate methods—not in services or controllers. When rules span multiple aggregates, break them into distinct synchronous and asynchronous phases.

Example: “Confirming an order reserves stock and deducts credit.”

Within the aggregate:

public void Confirm()
{
    if (Status != OrderStatus.Pending)
        throw new DomainException("Cannot confirm twice.");

    Status = OrderStatus.Confirmed;
    AddDomainEvent(new OrderConfirmed(Id));
}

The side effects—like reserving stock or adjusting credit—happen through domain event handlers, not directly inside the aggregate.

Event handler example:

public class OrderConfirmedHandler : INotificationHandler<OrderConfirmed>
{
    private readonly ICreditAccountRepository _creditRepo;
    private readonly IStockService _stockService;

    public async Task Handle(OrderConfirmed notification, CancellationToken token)
    {
        var credit = await _creditRepo.GetByCustomerAsync(notification.CustomerId);
        credit.Authorize(notification.Total);

        await _stockService.Reserve(notification.OrderId);
    }
}

This separation maintains transactional safety for the aggregate itself while allowing integration via events. Domain rules stay synchronous and testable inside aggregates, while side effects propagate asynchronously.

4.5 Handling Domain Events: Raising, Dispatching, and Testing in Isolation

Domain events model things that have happened. They’re first-class citizens of rich domain models. Each aggregate tracks them internally and exposes them for publication after persistence.

A minimal base type can help:

public abstract class AggregateRoot
{
    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;

    protected void AddDomainEvent(IDomainEvent @event) => _domainEvents.Add(@event);
    public void ClearDomainEvents() => _domainEvents.Clear();
}

A sample event:

public record OrderConfirmed(OrderId OrderId, Money Total) : IDomainEvent;

After saving via EF Core, you dispatch them using a unit-of-work or MediatR-based publisher:

public class DomainEventDispatcher
{
    private readonly IMediator _mediator;
    public async Task DispatchAsync(AggregateRoot aggregate)
    {
        foreach (var evt in aggregate.DomainEvents)
            await _mediator.Publish(evt);
        aggregate.ClearDomainEvents();
    }
}

Testing is simple—verify that the aggregate raises the expected events:

[Fact]
public void Confirm_Should_Raise_OrderConfirmed_Event()
{
    var order = TestData.CreatePendingOrder();
    order.Confirm(new CreditLimit(Money.From(1000, Currency.USD)));

    order.DomainEvents.Should().ContainSingle(e => e is OrderConfirmed);
}

Events let you express state changes declaratively, making both testing and integration clean.

4.6 Concurrency, Versioning, and Idempotency in the Aggregate

Rich models must handle concurrent operations safely. In EF Core, this is done with concurrency tokens. In Marten or event-sourced systems, it’s handled with stream versioning.

EF Core example:

public class Order
{
    public Guid Id { get; private set; }
    [Timestamp]
    public byte[] Version { get; private set; } = Array.Empty<byte>();
}

When two users try to confirm the same order concurrently, EF Core throws a DbUpdateConcurrencyException. You can resolve it by reloading the aggregate or merging changes.

In Marten, every aggregate stream has a version number:

var stream = session.Events.FetchStream(orderId);
if (stream.Version != expectedVersion)
    throw new ConcurrencyException();

To ensure external event handlers (like stock reservation) don’t duplicate work, use idempotency keys—for example, by tracking processed event IDs in an outbox or event consumer table. This guarantees that retried messages won’t reapply the same side effect.


5 Specifications: Expressing Complex Queries Without Leaking DAL Concerns

Specifications let you describe query intent instead of persistence mechanics. Instead of scattering LINQ filters throughout repositories and services, you express reusable, composable business queries.

5.1 The Specification Pattern: Intent-Centric Queries

A specification describes what to retrieve, not how. It encapsulates filters, includes, sorting, and projection logic. This keeps the repository interface small and expressive.

public class OrdersByCustomerSpec : Specification<Order>
{
    public OrdersByCustomerSpec(CustomerId id)
    {
        Query.Where(o => o.CustomerId == id)
             .Include(o => o.Lines)
             .OrderByDescending(o => o.CreatedAt);
    }
}

Your repository becomes generic:

public interface IReadRepository<T> where T : class
{
    Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec);
}

This decouples business queries from data access—repositories stay stable even when storage or ORM changes.

5.2 Ardalis.Specification in Practice

Ardalis.Specification provides an implementation that integrates naturally with EF Core. You can chain query modifiers fluently.

Filtering:

public class PendingOrdersSpec : Specification<Order>
{
    public PendingOrdersSpec() => Query.Where(o => o.Status == OrderStatus.Pending);
}

Including related data:

Query.Include(o => o.Customer)
     .ThenInclude(c => c.Account);

Sorting and pagination:

Query.OrderBy(o => o.CreatedAt)
     .Skip((page - 1) * pageSize)
     .Take(pageSize);

Projection:

public class OrderSummarySpec : Specification<Order, OrderSummaryDto>
{
    public OrderSummarySpec(CustomerId customerId)
    {
        Query.Where(o => o.CustomerId == customerId)
             .Select(o => new OrderSummaryDto(o.Id, o.Total, o.Status));
    }
}

This makes complex queries reusable and testable without repeating data-access code.

5.3 Keeping Specifications in the Domain vs. Application Layer

Not every specification belongs in the domain. A good rule of thumb:

  • Domain layer: Use specifications representing business meaning (e.g., “Orders pending confirmation,” “Customers at credit risk”).
  • Application layer: Use specifications tied to presentation or UI concerns (pagination, sorting, filtering).

For example:

  • OrdersAtRiskOfCreditBreachSpec belongs in the domain because it expresses a business condition.
  • PagedOrdersSpec belongs in the application layer—it’s purely a query concern.

Keeping this separation avoids polluting the domain with infrastructure or UX-specific logic.

5.4 Replacing Ad-Hoc Repository Methods

Without specifications, repositories grow unwieldy:

public interface IOrderRepository
{
    Task<IReadOnlyList<Order>> GetPendingAsync();
    Task<IReadOnlyList<Order>> GetByCustomerAsync(Guid id);
    Task<IReadOnlyList<Order>> GetOverdueAsync(DateTime cutoff);
}

Each new query means another method. With specifications:

public interface IOrderRepository : IRepository<Order> { }

And you simply pass different specs:

var orders = await _repo.ListAsync(new OrdersByCustomerSpec(customerId));

This reduces boilerplate, centralizes query intent, and encourages reuse.

5.5 Unit Testing Specifications (Without a Database)

Since specifications are just LINQ expressions, you can test them without any database dependency.

[Fact]
public void OrdersByCustomerSpec_Filters_Correctly()
{
    var spec = new OrdersByCustomerSpec(new CustomerId(Guid.NewGuid()));
    var query = spec.Evaluate(FakeOrders());

    query.Should().OnlyContain(o => o.CustomerId == spec.CustomerId);
}

Evaluate executes the expression tree against an in-memory collection. You can validate filters, includes, and projections easily.

For integration testing, wire it through EF Core’s in-memory provider:

var orders = await context.Orders.WithSpecification(spec).ToListAsync();

This ensures both domain logic and persistence mappings behave as expected.

5.6 Advanced Composition and Query Safety

Specifications are composable. You can chain them or inherit from base specifications.

public class BaseOrderSpec : Specification<Order>
{
    protected BaseOrderSpec() => Query.Include(o => o.Customer);
}

public class RecentConfirmedOrdersSpec : BaseOrderSpec
{
    public RecentConfirmedOrdersSpec() =>
        Query.Where(o => o.Status == OrderStatus.Confirmed &&
                         o.CreatedAt >= DateTime.UtcNow.AddDays(-30));
}

You can combine specifications dynamically using logical operators if you wrap them with expression combinators. This is powerful for filtering or policy checks.

To avoid N+1 issues, limit includes carefully and prefer projections for read-heavy paths. A good rule: don’t include more than what you need for the current use case. The specification should define exactly one data shape.


6 Domain Services: When (and Only When) Behavior Doesn’t Belong to an Entity

Domain services handle behavior that spans multiple aggregates or doesn’t naturally belong to a specific entity. They’re stateless, behavior-focused, and live inside the domain layer—not in the application layer.

6.1 Decision Guide: Entity/VO Method vs. Domain Service vs. Application Service

Ask these questions when deciding where logic belongs:

  • Does the behavior modify a single aggregate? → Put it in the entity or root.
  • Does it coordinate multiple aggregates or external systems? → Domain service.
  • Does it orchestrate commands or persistence? → Application service.

Example mapping:

BehaviorLocation
Order.AddLine()Entity
CreditPolicyService.CanAuthorize(Order, CreditAccount)Domain Service
ConfirmOrderCommandHandlerApplication Service

This separation keeps the model cohesive without dumping orchestration logic into entities.

6.2 Stateless Domain Services Coordinating Multiple Aggregates

Domain services often encapsulate cross-aggregate rules—pricing engines, credit checks, or stock availability.

Example: CreditPolicyService

public class CreditPolicyService
{
    private readonly ICreditAccountRepository _creditRepo;

    public async Task<bool> CanAuthorizeAsync(CustomerId customerId, Money amount)
    {
        var account = await _creditRepo.GetByCustomerAsync(customerId);
        return account.HasAvailable(amount);
    }
}

The service coordinates between aggregates (Customer, CreditAccount) but doesn’t hold state or orchestrate persistence. The aggregate still performs the actual mutation.

6.3 Interactions with Specifications for Read Models

Domain services often depend on specifications to read the right data. For example, a PricingService may need to apply discount tiers based on the customer segment.

public async Task<Money> CalculatePriceAsync(Sku sku, Quantity qty)
{
    var spec = new DiscountTierSpec(qty);
    var tier = await _discountRepo.FirstOrDefaultAsync(spec);
    return PricingRules.ApplyDiscount(sku.BasePrice, tier.Percentage);
}

This keeps the service small, composable, and free from direct EF Core dependencies.

Avoid the “anaemic by service” trap—don’t move aggregate logic into services unnecessarily. If a rule affects only one aggregate’s state, it belongs in that aggregate.

6.4 Idempotent Behaviors and Consistency Boundaries

Domain services frequently run in eventual consistency contexts—like handling outbox events or scheduled jobs. In these cases, operations must be idempotent.

Example pattern:

public async Task Handle(OrderConfirmed evt)
{
    if (await _processedStore.ExistsAsync(evt.Id))
        return;

    await _creditPolicy.ApplyAsync(evt.CustomerId, evt.Total);
    await _processedStore.MarkProcessedAsync(evt.Id);
}

By tracking processed event IDs, repeated deliveries don’t cause duplicate side effects.

Consistency boundaries also matter. Each aggregate should commit its own transaction. Domain services should not open distributed transactions—instead, use compensating actions or saga orchestration.

6.5 Testing Domain Services with Fake Repositories

Testing domain services is straightforward: inject fake repositories and specifications to isolate behavior.

[Fact]
public async Task CreditPolicyService_Should_Approve_When_Limit_Allows()
{
    var repo = new FakeCreditRepo(limit: 1000m);
    var service = new CreditPolicyService(repo);

    var result = await service.CanAuthorizeAsync(new CustomerId(Guid.NewGuid()), new Money(500m, Currency.USD));

    result.Should().BeTrue();
}

The service is deterministic and independent of infrastructure. You can validate coordination logic directly without touching the database.


7 Persistence and Integration Strategies That Preserve the Model

Once you’ve built expressive aggregates and rich value objects, the next challenge is preserving their integrity through persistence. Most architectures fall apart here: persistence frameworks try to flatten your model into relational rows, and developers start leaking data access logic into the domain. The key to maintaining a healthy model is ensuring your persistence strategy respects your domain boundaries rather than shaping them.

7.1 EF Core 10 Preview Highlights Relevant to DDD

Entity Framework Core 10 (part of the .NET 9 wave) introduces several improvements that make it friendlier for DDD-style architectures. Its new features let you persist rich domain models without sacrificing expressiveness or performance.

7.1.1 JSON Columns

EF Core 10 adds first-class support for JSON columns in relational databases like SQL Server and PostgreSQL. This allows aggregates to store nested value objects or owned collections directly inside a single column, preserving atomicity.

builder.Entity<Order>()
    .OwnsMany(o => o.Lines, l =>
    {
        l.ToJson(); // Stores the collection as a JSON array
    });

This feature reduces table explosion for deeply nested aggregates while keeping the domain structure intact.

7.1.2 Improved Value Converters

EF Core 10’s value converter pipeline can now handle complex value objects more efficiently, especially when mapping to primitive columns.

builder.Property(o => o.Total)
    .HasConversion(
        v => v.Amount,     // to provider
        v => Money.From(v, Currency.USD)); // from provider

You can centralize these converters in reusable configuration classes, allowing consistent persistence of value objects across aggregates.

7.1.3 Raw SQL Mapping and Performance Fixes

The new raw SQL mapping enhancements allow projecting directly into aggregate snapshots without fragile reflection. Combined with compiled queries, EF Core 10 can now serve high-throughput read models while keeping aggregate roots pure.

7.1.4 Change Tracking and Snapshot Optimization

EF Core’s improved snapshot change tracking reduces the cost of updating large owned graphs. This matters for aggregates with many value object properties, keeping your persistence consistent with your model design.

7.2 Mapping Aggregates and Value Objects

A clean persistence model mirrors your domain’s shape without leaking ORM concerns. EF Core’s owned entity types and backing fields allow this alignment naturally.

7.2.1 Owned Entity Types

Use owned types for immutable value objects. EF Core ensures they’re persisted as part of their owner aggregate.

builder.Entity<Order>().OwnsOne(o => o.Total, m =>
{
    m.Property(p => p.Amount).HasColumnName("TotalAmount");
    m.Property(p => p.Currency).HasColumnName("CurrencyCode");
});

This preserves the atomic nature of aggregates while avoiding normalization overhead.

7.2.2 Backing Fields and Shadow FKs

When you encapsulate collections, you often hide them behind private fields. EF Core supports backing fields so your domain model stays clean.

builder.Entity<Order>()
    .Metadata
    .FindNavigation(nameof(Order.Lines))!
    .SetField("_lines");

Shadow foreign keys let EF manage relationships without polluting your model with persistence identifiers.

7.2.3 Value Converters for Structs and Records

When using record struct or strongly typed IDs, register converters globally:

builder
    .Properties<StronglyTypedId>()
    .HaveConversion(id => id.Value, value => new StronglyTypedId(value));

This allows EF to map strongly typed identifiers seamlessly to database primitives like Guid or int.

7.3 Repository Patterns That Don’t Fight EF

A repository shouldn’t abstract EF so much that it hides its strengths. The right pattern keeps the domain ignorant of EF but leverages EF’s query power.

7.3.1 Generic Repository with Specifications

A minimal, domain-aligned repository interface:

public interface IRepository<T> where T : AggregateRoot
{
    Task<T?> GetByIdAsync(Guid id);
    Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec);
    Task SaveAsync(T aggregate);
}

And an EF implementation that embraces change tracking:

public class EfRepository<T> : IRepository<T> where T : AggregateRoot
{
    private readonly AppDbContext _db;

    public async Task SaveAsync(T aggregate)
    {
        await _db.SaveChangesAsync();
    }

    public Task<T?> GetByIdAsync(Guid id) =>
        _db.Set<T>().FindAsync(id).AsTask();
}

7.3.2 When to Skip a Repository

Sometimes a repository adds no value—for example, when you’re building a CQRS read model or doing projections. In those cases, it’s fine to use a DbContext-backed query service. The repository pattern is for aggregates that require invariants, not generic queries.

7.4 Transactions, Outbox, and Domain Events

In a real system, persistence and event publishing must be atomic. EF Core can’t publish domain events within the same transaction automatically, so you implement an outbox pattern.

7.4.1 Outbox Table

Store pending events inside the same database transaction:

public class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = string.Empty;
    public string Payload { get; set; } = string.Empty;
    public DateTime CreatedUtc { get; set; }
}

When saving aggregates, serialize their domain events into the outbox:

foreach (var evt in aggregate.DomainEvents)
{
    _db.Outbox.Add(new OutboxMessage
    {
        Id = Guid.NewGuid(),
        Type = evt.GetType().Name,
        Payload = JsonSerializer.Serialize(evt),
        CreatedUtc = DateTime.UtcNow
    });
}
await _db.SaveChangesAsync();

Then a background publisher processes outbox messages reliably.

7.4.2 Marten’s Built-In Event Store

If you’re using Marten (PostgreSQL), you get event storage out of the box:

session.Events.Append(order.Id, new OrderConfirmed(order.Id, order.Total));
await session.SaveChangesAsync();

Each stream represents an aggregate. Marten guarantees versioning and event ordering, simplifying concurrency and outbox management.

7.5 Projections and Read Models

As systems grow, aggregates are optimized for correctness, not query shape. Read models let you query efficiently without compromising domain integrity.

7.5.1 EF Core Projections

Project directly to DTOs or summaries without materializing aggregates:

var summaries = await _db.Orders
    .Select(o => new OrderSummaryDto(o.Id, o.CustomerId, o.Total.Amount, o.Status))
    .ToListAsync();

7.5.2 CQRS Split

Introduce CQRS when:

  • Reads outnumber writes significantly.
  • Queries require joins that don’t map neatly to aggregates.
  • You need multiple query stores (SQL + Elastic + cache).

Keep the write model pure (aggregates, invariants, events) and the read model optimized for display or reporting.

7.6 Migrations and Evolution

Even well-designed domains evolve. Schema migrations must keep up without downtime or data loss.

7.6.1 Evolving Value Objects

When refactoring a value object—for example, splitting Address into BillingAddress and ShippingAddress—migrate data safely using temporary columns and background jobs. Avoid breaking persistence contracts by versioning VO converters.

7.6.2 Zero-Downtime Strategy

  • Deploy schema changes first (additive).
  • Deploy application changes next.
  • Remove deprecated columns last.

Using EF Core’s migrations and feature flags lets you roll out changes safely in production.

7.7 Performance Checklist

Performance tuning should complement—not distort—the domain. Here’s a checklist that keeps aggregates efficient while preserving design clarity.

  • Query shaping: Use .Include() or .Select() carefully to avoid N+1 issues.
  • Compiled queries: Precompile high-frequency queries to reduce expression parsing.
  • Projection-first reads: Don’t hydrate aggregates when you only need summaries.
  • Batching: Group related updates in single transactions.
  • Pagination: Always paginate queries exposed to user interfaces.
  • Caching: Cache specifications or projections, not aggregates.
  • Specification safety: Keep expressions database-translatable; avoid in-memory filters on large datasets.

Example of a compiled projection:

private static readonly Func<AppDbContext, Guid, Task<OrderSummaryDto?>> _getOrderSummary =
    EF.CompileAsyncQuery((AppDbContext db, Guid id) =>
        db.Orders
          .Where(o => o.Id == id)
          .Select(o => new OrderSummaryDto(o.Id, o.Total.Amount, o.Status))
          .FirstOrDefault());

7.8 Observability: Logging, Tracing, and Metrics

Observability completes the feedback loop between design and runtime. Rich domain models give you natural event hooks for tracing.

7.8.1 Logging Domain Events

Each domain event can be logged with context:

_logger.LogInformation("Order {OrderId} confirmed for {Amount}", evt.OrderId, evt.Total);

7.8.2 Distributed Tracing

Trace aggregate operations using Activity:

using var activity = _activitySource.StartActivity("Order.Confirm");
order.Confirm(credit);
await _orderRepo.SaveAsync(order);

Attach correlation IDs to propagate context through the pipeline.

7.8.3 Measuring Invariant Drift

Track metrics around business invariants—like the percentage of orders exceeding credit limits or failed stock reservations. Use these metrics to detect systemic issues early.


8 A Realistic End-to-End Case Study: B2B Ordering with Credit Limits, Tiered Discounts, and Availability

Let’s bring all of this together in a realistic B2B ordering system. This example shows how rich domain models, value objects, and specifications interact in a complete scenario.

8.1 Domain Overview and Ubiquitous Language

We’ll design a simplified but representative system used by distributors placing wholesale orders.

8.1.1 Actors and Aggregates

  • Customer – entity with CreditAccount
  • CreditAccount – aggregate managing credit exposure
  • Order – aggregate root containing OrderLine entities
  • CatalogItem – represents sellable items
  • WarehouseStock – tracks inventory per SKU and warehouse

8.1.2 Key Invariants

  • Orders must not exceed customer credit limit.
  • Discounts depend on tier thresholds.
  • Taxes apply per region.
  • Stock reservations must respect warehouse availability.
  • Minimum order quantity enforced per item.

8.2 Value Objects We’ll Implement

These value objects give the model precision and remove ambiguity.

8.2.1 Core Value Objects

public readonly record struct Money(decimal Amount, Currency Currency);
public readonly record struct Percentage(decimal Value);
public sealed class Sku : ValueOf<string, Sku> { protected override void Validate() => Guard.Against.NullOrEmpty(Value); }
public sealed class Quantity : ValueOf<int, Quantity> { protected override void Validate() { if (Value <= 0) throw new DomainException("Invalid quantity"); } }
public sealed class TaxRate : ValueOf<decimal, TaxRate> { protected override void Validate() => Guard.Against.OutOfRange(Value, 0, 1); }

8.2.2 Time Concepts with NodaTime

public sealed record FulfillmentWindow(ZonedDateTime Start, ZonedDateTime End)
{
    public bool Includes(Instant instant) => instant >= Start.ToInstant() && instant <= End.ToInstant();
}

8.3 Aggregate Design and Behaviors

8.3.1 Order

public class Order : AggregateRoot
{
    private readonly List<OrderLine> _lines = new();
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;
    public Money Total => Money.From(_lines.Sum(l => l.Subtotal.Amount), Currency.USD);

    public void AddLine(Sku sku, Quantity qty, Money price)
    {
        if (_lines.Any(l => l.Sku == sku))
            throw new DomainException("Duplicate SKU");
        _lines.Add(new OrderLine(sku, qty, price));
    }

    public void Confirm(CreditLimit limit)
    {
        if (Total > limit.Available)
            throw new DomainException("Credit limit exceeded");
        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmed(Id, Total));
    }
}

8.3.2 CreditAccount

public class CreditAccount : AggregateRoot
{
    public Money Limit { get; private set; }
    public Money Exposure { get; private set; }

    public bool CanAuthorize(Money amount) => Exposure + amount <= Limit;

    public void Capture(Money amount)
    {
        if (!CanAuthorize(amount))
            throw new DomainException("Insufficient credit");
        Exposure += amount;
        AddDomainEvent(new CreditAuthorized(Id, amount));
    }
}

8.3.3 Domain Events

public record OrderConfirmed(OrderId Id, Money Total) : IDomainEvent;
public record CreditAuthorized(CreditAccountId Id, Money Amount) : IDomainEvent;
public record StockReserved(Sku Sku, Quantity Qty) : IDomainEvent;

8.4 Specifications and Read Models

8.4.1 OrdersByCustomerAndDateRange

public class OrdersByCustomerAndDateRangeSpec : Specification<Order>
{
    public OrdersByCustomerAndDateRangeSpec(CustomerId id, DateTime from, DateTime to)
    {
        Query.Where(o => o.CustomerId == id && o.CreatedAt >= from && o.CreatedAt <= to)
             .Include(o => o.Lines);
    }
}

8.4.2 CustomersAtRiskOfCreditBreach

public class CustomersAtRiskOfCreditBreachSpec : Specification<Customer, CreditRiskDto>
{
    public CustomersAtRiskOfCreditBreachSpec(decimal threshold)
    {
        Query.Where(c => c.CreditAccount.Exposure.Amount / c.CreditAccount.Limit.Amount >= threshold)
             .Select(c => new CreditRiskDto(c.Id, c.Name, c.CreditAccount.Exposure, c.CreditAccount.Limit));
    }
}

8.4.3 LowStockItems

public class LowStockItemsSpec : Specification<WarehouseStock>
{
    public LowStockItemsSpec(int minQty) => Query.Where(w => w.Available.Value < minQty);
}

8.5 Domain Services

8.5.1 PricingService

public class PricingService
{
    public Money ApplyDiscount(Money basePrice, DiscountTier tier) =>
        Money.From(basePrice.Amount * (1 - tier.Percentage.Value), basePrice.Currency);
}

8.5.2 CreditPolicyService

public class CreditPolicyService
{
    private readonly ICreditAccountRepository _repo;
    public async Task<bool> CanAuthorizeAsync(CustomerId id, Money amount)
    {
        var account = await _repo.GetByCustomerAsync(id);
        return account.CanAuthorize(amount);
    }
}

8.5.3 AvailabilityService

public class AvailabilityService
{
    private readonly IWarehouseStockRepository _repo;
    public async Task ReserveAsync(Order order)
    {
        foreach (var line in order.Lines)
        {
            var stock = await _repo.GetForSkuAsync(line.Sku);
            stock.Reserve(line.Quantity);
        }
    }
}

8.6 Persistence and Integration

8.6.1 EF Core Mapping

builder.Entity<Order>().OwnsMany(o => o.Lines, l =>
{
    l.WithOwner().HasForeignKey("OrderId");
    l.Property(p => p.Sku).HasConversion(s => s.Value, v => new Sku(v));
});
builder.Entity<Order>().Property<byte[]>("Version").IsRowVersion();

8.6.2 Outbox Pattern

public class OutboxPublisher
{
    public async Task PublishPendingAsync()
    {
        var pending = await _db.Outbox.Where(o => !o.Processed).ToListAsync();
        foreach (var msg in pending)
        {
            await _bus.PublishAsync(msg.Type, msg.Payload);
            msg.Processed = true;
        }
        await _db.SaveChangesAsync();
    }
}

8.6.3 Marten Variant

session.Events.StartStream<Order>(order.Id, new OrderCreated(order.Id));
session.Events.Append(order.Id, new OrderConfirmed(order.Id, order.Total));
await session.SaveChangesAsync();

8.7 Testing Strategy

8.7.1 Specification Tests

[Fact]
public void LowStockSpec_Should_Filter_Correctly()
{
    var spec = new LowStockItemsSpec(10);
    var result = spec.Evaluate(FakeStocks());
    result.Should().OnlyContain(s => s.Available.Value < 10);
}

8.7.2 Aggregate Tests

[Fact]
public void Order_Should_Throw_When_Credit_Exceeded()
{
    var order = TestData.CreateOrder(1200m);
    var limit = new CreditLimit(Money.From(1000, Currency.USD));
    Assert.Throws<DomainException>(() => order.Confirm(limit));
}

8.7.3 Domain Service Tests

[Fact]
public async Task CreditPolicy_Should_Reject_When_OverLimit()
{
    var repo = new FakeCreditRepo(limit: 500m);
    var service = new CreditPolicyService(repo);
    var result = await service.CanAuthorizeAsync(CustomerId.New(), Money.From(600, Currency.USD));
    result.Should().BeFalse();
}

8.7.4 Integration Tests

Use EF Core InMemory or SQLite for local testing and Marten/Postgres for event flow verification.

8.8 Migration Guidance for Existing Anemic Systems

8.8.1 Start by Extracting Value Objects

Replace primitive fields with strong types. Each replacement enforces invariants automatically.

8.8.2 Move Invariants into Aggregates

Convert procedural validations in services into methods like Order.Confirm() or Customer.ChangeAddress().

8.8.3 Introduce Specifications

Replace duplicated LINQ queries with reusable specifications, clarifying query intent.

8.8.4 Retire “God Services”

Move logic back into entities and value objects; leave cross-aggregate coordination to domain services.

8.8.5 Track Metrics

Measure defect reduction in rule enforcement, code churn in domain areas, and query latency after specification refactors. The results are usually dramatic—less coupling, fewer rule violations, and clearer collaboration between code and business intent.

Advertisement