Skip to content

DDD Tactical Patterns

Key Points

  • Entities have identity (an Id) and lifecycle. Value objects are defined by their attributes (no identity).
  • Aggregate root is the entity that owns a cluster of related objects; the boundary of consistency.
  • Domain events capture what happened ("OrderConfirmed"); fire after state changes; consumed by other aggregates or external integrations.
  • Invariants belong to the aggregate — never let outside code put it in an invalid state.
  • Aggregate per transaction — modify one aggregate per SaveChanges. Cross-aggregate updates use eventual consistency.
  • Bounded context = one ubiquitous language. "Customer" in Sales context isn't the same shape as "Customer" in Shipping.

Concepts (deep dive)

Entity vs Value Object

// Entity (identity)
public class Customer
{
    public CustomerId Id { get; private set; }
    public string Name { get; private set; }

    // Two Customers with same Name are different if Ids differ.
}

// Value object (no identity)
public readonly record struct Money(decimal Amount, string Currency);

// Two Moneys are equal iff Amount and Currency match.
var price1 = new Money(10, "USD");
var price2 = new Money(10, "USD");
Console.WriteLine(price1 == price2);   // True

record struct is the perfect VO host: value equality, immutable, no boxing in many cases. Wrap primitives (CustomerId, Email) in VOs to gain type safety.

Aggregate root and consistency boundary

public class Order   // aggregate root
{
    private readonly List<OrderLine> _lines = new();
    public OrderId Id { get; private set; }
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
    public Money Total { get; private set; }

    public void AddLine(ProductId productId, int qty, Money unitPrice)
    {
        if (_lines.Sum(l => l.LineTotal.Amount) > 10_000m)
            throw new InvariantViolation("max order total exceeded");

        _lines.Add(new OrderLine(productId, qty, unitPrice));
        Total = new Money(_lines.Sum(l => l.LineTotal.Amount), unitPrice.Currency);
    }
}

public class OrderLine    // entity within the aggregate
{
    public OrderLineId Id { get; }
    public ProductId ProductId { get; }
    public int Quantity { get; private set; }
    public Money UnitPrice { get; }
    public Money LineTotal => new(UnitPrice.Amount * Quantity, UnitPrice.Currency);
}

Rules: - Reference other aggregates by Id only, not by direct reference. - Modify one aggregate per transaction. - Invariants live in the aggregate — not in services that mutate from outside. - The aggregate exposes only operations that maintain invariants.

Domain events

public abstract record DomainEvent(DateTimeOffset OccurredAt);
public record OrderConfirmed(OrderId OrderId, DateTimeOffset OccurredAt) : DomainEvent(OccurredAt);

public class Order
{
    private readonly List<DomainEvent> _events = new();
    public IReadOnlyList<DomainEvent> Events => _events.AsReadOnly();

    public void Confirm()
    {
        Status = OrderStatus.Confirmed;
        _events.Add(new OrderConfirmed(Id, DateTimeOffset.UtcNow));
    }

    public void ClearEvents() => _events.Clear();
}

After SaveChanges, dispatch events to handlers (in-process MediatR or out-of-process via outbox). Events let other aggregates react: OrderConfirmed → email handler sends confirmation; analytics handler records the event.

public class OrderConfirmedNotificationHandler : INotificationHandler<OrderConfirmed>
{
    public Task Handle(OrderConfirmed e, CancellationToken ct) =>
        _email.SendOrderConfirmationAsync(e.OrderId, ct);
}

Aggregate per transaction

public async Task TransferFundsAsync(AccountId from, AccountId to, Money amount)
{
    var src = await _repo.GetAsync(from);
    var dst = await _repo.GetAsync(to);
    src.Withdraw(amount);   // mutate aggregate 1
    dst.Deposit(amount);    // mutate aggregate 2
    await _repo.SaveAsync(src);
    await _repo.SaveAsync(dst);   // ❌ second SaveChanges; partial-failure risk
}

Two aggregates in one logical operation. The DDD answer: eventual consistency. Process money transfer as:

  1. Withdraw from src; raise MoneyDebited event.
  2. Event handler (saga) credits dst; raise MoneyCredited.
  3. On dst failure, raise compensating event to refund src.

Or: combine into a single aggregate ("MoneyTransfer") if appropriate. Or accept eventual consistency.

Value object discipline

// ❌ Primitive obsession
public class Order { public string Email; public string CustomerId; public decimal Amount; }

// ✅ Wrapped in VOs; type-safe; can carry validation
public class Order { public Email Email; public CustomerId CustomerId; public Money Amount; }

public readonly record struct Email
{
    public string Value { get; }
    public Email(string value)
    {
        if (!IsValid(value)) throw new ArgumentException(nameof(value));
        Value = value;
    }
    private static bool IsValid(string s) => s.Contains('@');
}

VOs are immutable; comparable by value; encapsulate invariants. Wrap primitives to make the type system enforce semantics.

Bounded contexts

   ┌───────────────────────┐    ┌───────────────────────┐
   │ Sales context         │    │ Shipping context      │
   │   Customer            │    │   Customer            │
   │     - Email           │    │     - DeliveryAddress │
   │     - PaymentMethod   │    │     - PreferredCarrier│
   │   Order               │    │   Shipment            │
   └───────────────────────┘    └───────────────────────┘
       integration via                shared kernel:
       events/messages                 OrderId, CustomerId

Different bounded contexts can have different shapes for the same conceptual entity. Don't try to unify "Customer" across contexts; they answer different questions.

Integration patterns: shared kernel (small set of common types), customer/supplier (one provides, one consumes), anti-corruption layer (translate between models).

Specifications

public class ActiveCustomersSpec : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression() => c => c.IsActive;
}

var actives = await repo.QueryAsync(new ActiveCustomersSpec());

Reusable query criteria as objects. Combine via .And(other), .Or(other). Useful when query criteria are domain concepts ("delinquent account", "preferred customer").

Domain services vs application services

  • Domain service — operation that doesn't naturally belong to one entity. Pure domain logic. Example: MoneyTransferService.Calculate(...).
  • Application service — orchestrator: load aggregate, invoke domain operations, save. Lives in application layer.

Don't confuse the two; both have valid roles.

CQRS pairs naturally

DDD's complexity often calls for split read/write models. See CQRS & MediatR.


Code: correct vs wrong

❌ Wrong: external mutation of aggregate state

var order = repo.Get(id);
order.Status = OrderStatus.Confirmed;   // ❌ skips invariants
order.UpdatedAt = DateTime.UtcNow;      // and audit

✅ Correct: aggregate method

var order = repo.Get(id);
order.Confirm();   // encapsulates the invariants

❌ Wrong: cross-aggregate references

public class Order { public Customer Customer { get; set; } }   // ❌ direct reference

✅ Correct: by Id

public class Order { public CustomerId CustomerId { get; private set; } }
// Look up Customer separately when needed.

❌ Wrong: primitive obsession

void Process(string email, decimal amount)

✅ Correct: VOs

void Process(Email email, Money amount)

Design patterns for this topic

Pattern 1 — "Aggregate as consistency boundary"

  • Intent: invariants enforced by the root.

Pattern 2 — "Reference other aggregates by Id"

  • Intent: keep aggregates loosely coupled.

Pattern 3 — "Domain events for cross-aggregate effects"

  • Intent: decouple downstream reactions.

Pattern 4 — "Wrap primitives in value objects"

  • Intent: type safety; encapsulated invariants.

Pattern 5 — "Bounded contexts with explicit integration"

  • Intent: localized models; explicit boundaries.

Pros & cons / trade-offs

Aspect Pros Cons
Aggregates Encapsulated invariants Requires careful boundary design
VOs Type safety Many small types
Domain events Decoupling Async complexity
Bounded contexts Localized models Must integrate explicitly
One aggregate per tx Simple consistency Forces eventual consistency cross-aggregate

When to use / when to avoid

  • Use DDD tactical patterns for complex business domains.
  • Use VOs for any value-like concept (Money, Email, Address).
  • Use domain events for downstream reactions.
  • Avoid full DDD for simple CRUD apps.
  • Avoid cross-aggregate references — pass IDs.

Interview Q&A

Q1. Entity vs Value Object? Entity has identity (Id) and lifecycle. VO is defined by its attributes; equal by value.

Q2. What's an aggregate root? The entry point to a consistency cluster. External code accesses entities/VOs only through the root.

Q3. Why "modify one aggregate per transaction"? Aggregates encapsulate invariants. Modifying multiple in one tx breaks the boundary; failure recovery becomes complex. Use events for cross-aggregate.

Q4. What's a domain event? An immutable record that something happened ("OrderConfirmed"). Fired after state change; consumed by handlers.

Q5. Why reference other aggregates by Id? Avoids loading huge object graphs and tight coupling; keeps consistency boundaries clear.

Q6. Bounded contexts? A logical boundary where one ubiquitous language applies. Same conceptual entity may have different shapes across contexts.

Q7. What's primitive obsession? Using primitives (string, int, decimal) for domain concepts (Email, CustomerId, Money). VOs solve it.

Q8. Domain service vs application service? Domain service: pure domain logic that doesn't fit one entity. Application service: orchestrator (load, invoke, save).

Q9. How do you implement eventual consistency between aggregates? Domain events + handler that mutates the other aggregate; outbox pattern for reliable delivery.

Q10. Specification pattern? Reusable query criteria as objects. Compose with And/Or. Used as parameters to repository queries.


Gotchas / common mistakes

  • ⚠️ External state mutation — bypasses invariants.
  • ⚠️ Cross-aggregate references — coupling, partial loads.
  • ⚠️ Primitive obsession — type system can't help.
  • ⚠️ Anemic domain — services do everything.
  • ⚠️ Domain events with side effects in aggregate — aggregate should record events, not dispatch them.

Further reading