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 (
TotalandStatuschecks) - 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:
OrderandCreditAccountare separate aggregates.Order.Confirm()raises aCreditAuthorizedevent consumed asynchronously byCreditAccount.
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
classwith private setters.
2.3.2 Value Objects
Immutable, equality-by-value types representing measurable or descriptive concepts.
- Example:
Money,EmailAddress,Quantity - Represented as
record structorreadonly struct
2.3.3 Aggregates and Roots
Aggregates enforce invariants across entities and value objects. The root controls access to children.
- Example:
Order(root) managesOrderLineentities. - 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:
Instantfor absolute timeZonedDateTimefor business time zonesDurationfor 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.
| Concept | Typical Type | Better Type |
|---|---|---|
| Money | decimal | Money |
| Percentage | decimal | Percentage |
string | EmailAddress | |
| SKU | string | Sku |
| Quantity | int | Quantity |
| Tax Rate | decimal | TaxRate |
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:
OrdersAtRiskOfCreditBreachSpecbelongs in the domain because it expresses a business condition.PagedOrdersSpecbelongs 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:
| Behavior | Location |
|---|---|
Order.AddLine() | Entity |
CreditPolicyService.CanAuthorize(Order, CreditAccount) | Domain Service |
ConfirmOrderCommandHandler | Application 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
OrderLineentities - 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.