Chapter 25 · Production Architecture, DDD/CQRS Integration, Reliability, and Capstone

Aggregate Persistence, Owned/Complex Types, Value Objects, Domain Events, and Transactional Outbox Design

Persist ServiceHub aggregate invariants, complex value objects and domain events while using the existing relational outbox to make business changes and durable integration intent atomic without pretending external messaging joins SaveChanges.

Advanced210–300 minutesaggregate + transactional-outbox labEF Core 10.0.11 · Microsoft.EntityFrameworkCore.Sqlite 10.0.11 · dotnet-ef 10.0.11 · .NET 10.0.11 · SDK 10.0.400Mandatory free local SQLite path · Dapper 2.1.79 optional · production-like server provider optionalArchitecture/package/platform status reviewed: August 27, 2026

Learning outcomes

01

Model the WorkOrder aggregate so invariants stay inside domain operations while EF persists encapsulated state.

02

Prefer EF Core 10 complex types for ServiceAddress value semantics and understand when owned entity types remain appropriate.

03

Capture domain events as in-memory intent and persist integration-event outbox rows in the same database transaction.

04

Explain why publishing to an external broker inside SavingChanges is a dual-write failure.

05

Design idempotent outbox dispatch with observable processed state and failure retry.

06

Verify aggregate + outbox atomicity with transaction and crash/failure tests.

1. Aggregate persistence is about consistency boundaries, not object graphs

In domain-driven design (DDD), an aggregate is a cluster of domain objects changed through one consistency boundary. ServiceHub’s WorkOrder already has encapsulated operations, Revision concurrency, notes/tags, ServiceAddress, tenant state and soft-delete policy. EF should persist that model without moving invariants into controllers or public setters.

2. Preserve the established complex value object

C# · existing ServiceAddress value semantics
public sealed record ServiceAddress(    string Line1,    string City,    string Region,    string PostalCode,    string CountryCode);builder.ComplexProperty(x => x.ServiceAddress);

EF Core 10 complex types have no identity and use value semantics, which fits an address owned conceptually by the WorkOrder. Owned entity types remain useful when the dependent needs entity-like navigation/collection mapping, but they carry hidden identity semantics. Do not choose by naming convention; choose by domain semantics and required relational mapping.

3. Domain events are in-memory facts about completed domain decisions

C# · domain event collection inside the aggregate
public interface IDomainEvent{    Guid EventId { get; }    DateTimeOffset OccurredUtc { get; }}public sealed record WorkOrderSummaryRevised(    Guid EventId, DateTimeOffset OccurredUtc, int WorkOrderId,    string TenantId, Guid Revision) : IDomainEvent;public sealed partial class WorkOrder{    private readonly List<IDomainEvent> _domainEvents = [];    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;    public void ReviseSummary(string summary, TimeProvider clock)    {        SetSummary(summary);        AdvanceRevision();        _domainEvents.Add(new WorkOrderSummaryRevised(            Guid.NewGuid(), clock.GetUtcNow(), Id, TenantId, Revision));    }    public void ClearDomainEvents() => _domainEvents.Clear();}

The event says the aggregate accepted a change; it is not yet proof that a remote subscriber received anything. That requires a durable integration mechanism.

4. Deliberate failure: publish externally during SavingChanges

C# · WRONG: external side effect before database commit
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(    DbContextEventData eventData,    InterceptionResult<int> result,    CancellationToken ct = default){    await broker.PublishAsync("WorkOrderChanged", payload, ct);    return result;}

If the broker succeeds and the database transaction later fails, consumers observe a phantom event. If the database commits and the process dies before a later publish, the event is lost. A SaveChanges interceptor cannot make two independent resource managers atomic.

5. Transactional outbox: persist intent beside business state

C# · existing outbox entity from Chapter 12
public sealed class OutboxMessage{    public Guid Id { get; init; }    public DateTime OccurredUtc { get; init; }    public string Type { get; init; } = null!;    public string PayloadJson { get; init; } = null!;    public DateTime? ProcessedUtc { get; set; }}
C# · capture aggregate events before commands execute
foreach (var entry in db.ChangeTracker.Entries<WorkOrder>()){    foreach (var evt in entry.Entity.DomainEvents)    {        db.Set<OutboxMessage>().Add(new OutboxMessage        {            Id = evt.EventId,            OccurredUtc = evt.OccurredUtc.UtcDateTime,            Type = evt.GetType().Name,            PayloadJson = JsonSerializer.Serialize(evt, evt.GetType())        });    }}await db.SaveChangesAsync(ct); // work_order UPDATE + outbox INSERT, one DB transactionforeach (var entry in db.ChangeTracker.Entries<WorkOrder>())    entry.Entity.ClearDomainEvents();

Use an interceptor or explicit application service to perform this capture, but test ordering carefully. Clear in-memory events only after a successful commit path; failed SaveChanges must remain retry-safe.

6. Dispatch after commit and assume at-least-once delivery

C# · separate outbox relay
var pending = await db.Set<OutboxMessage>()    .Where(x => x.ProcessedUtc == null)    .OrderBy(x => x.OccurredUtc)    .Take(100)    .ToListAsync(ct);foreach (var message in pending){    await publisher.PublishAsync(        message.Type, message.PayloadJson,        idempotencyKey: message.Id.ToString(), ct);    message.ProcessedUtc = clock.GetUtcNow().UtcDateTime;    await db.SaveChangesAsync(ct);}

A crash after publish but before ProcessedUtc can cause replay, so consumers need idempotency/deduplication. The outbox solves the local atomicity gap; it does not provide exactly-once network delivery.

7. Evidence: one transaction contains aggregate and outbox commands

Expected failure/atomicity test
Arrange: WorkOrder revision event + outbox captureInject: force a database constraint failure after event creationExpect: SaveChanges throws; neither WorkOrder change nor OutboxMessage existsRetry corrected command: both business change and outbox row commitDispatcher crash after broker publish: same OutboxMessage may be retried -> consumer dedupes by Id

Use a fresh context after failure to verify store state. Do not infer atomicity from the original tracker.

8. Mandatory lab: aggregate → outbox → relay

  1. Keep the Chapter 06 ServiceAddress, Chapter 04 Revision and Chapter 22 tenant model.
  2. Add one domain event to ReviseSummary.
  3. Persist an OutboxMessage in the same SaveChanges transaction.
  4. Force a database failure and prove neither change is committed.
  5. Run a local fake publisher relay and mark the outbox row processed only after publish.
  6. Simulate relay replay and prove idempotency by message ID.

9. Production judgment and bridge

Keep domain invariants in aggregate methods; use complex/owned mappings according to identity semantics; treat domain events as intent; use a transactional outbox for durable integration intent and an independently retryable dispatcher. The next lesson separates write-model requirements from read-model/hot-path tooling without turning CQRS into mandatory distributed infrastructure.

Check your understanding

  1. Why is ServiceAddress a good complex type?
  2. Why not publish externally inside SavingChanges?
  3. What does the outbox make atomic?
  4. Why can outbox delivery still repeat?
  5. When should domain events be cleared?
  6. Does using an interceptor remove the need for tests?
Review the answers

1. It is a value object without identity and benefits from value semantics.

2. The broker and database are independent resources, so success in one can be followed by failure in the other.

3. The business database change and durable message intent stored in the same database transaction.

4. A crash can occur after publish but before marking the row processed, so relays/consumers must be idempotent.

5. After a successful persistence path, with retry behavior designed so failed SaveChanges does not silently lose intent.

6. No. Ordering, failure semantics, retries and transaction boundaries must be verified explicitly.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n