Part X: Introduce Domain Events
"Don't ask. Tell. Then let others react." -- Udi Dahan
Five phases done. Characterization tests hold the safety net (Part V). Bounded context libraries enforce structural boundaries (Part VI). ACLs protect the crossings (Part VII). Value Objects eliminate primitive obsession (Part VIII). Aggregates enforce invariants (Part IX). The architecture diagram looks clean. The compiler enforces the boundaries.
But one problem remains, and it is the ugliest one. SubscriptionService and BillingService still call each other.
This is not a clean dependency arrow. It is a circle. And circles are the architectural equivalent of a deadlock -- once two contexts depend on each other, changes in either context break both. We diagnosed this in Part II: BillingService takes SubscriptionService as a constructor parameter, and SubscriptionService calls BillingService at runtime through DI. Two teams editing each other's files. Merge conflicts every sprint. No way to deploy one without the other.
Phase 6 breaks the circle. The tool is domain events.
The Circular Dependency Problem
Let's look at what we diagnosed in Part II, now through the lens of bounded contexts.
The Subscriptions context needs Billing for one thing: when a customer changes plan, Subscriptions asks Billing to generate a prorated invoice. The Billing context needs Subscriptions for one thing: when a payment fails three times, Billing tells Subscriptions to suspend the subscription.
Both directions are legitimate business requirements. The problem is not that they communicate. The problem is how they communicate -- by calling each other's methods directly.
// In SubscriptionService.cs (Subscriptions context)
public async Task ChangePlan(SubscriptionId subscriptionId, PlanId newPlanId)
{
var subscription = await _repository.GetAsync(subscriptionId);
var oldPlan = subscription.Plan;
var newPlan = await _planRepository.GetAsync(newPlanId);
var proratedFraction = subscription.Period.ProratedFraction(DateOnly.FromDateTime(DateTime.UtcNow));
subscription.ChangePlan(newPlan, proratedFraction);
await _repository.SaveAsync(subscription);
// Direct call to Billing context -- THIS IS THE PROBLEM
await _billingService.GenerateProratedInvoice(
subscriptionId, oldPlan.Id, newPlan.Id, proratedFraction);
}// In SubscriptionService.cs (Subscriptions context)
public async Task ChangePlan(SubscriptionId subscriptionId, PlanId newPlanId)
{
var subscription = await _repository.GetAsync(subscriptionId);
var oldPlan = subscription.Plan;
var newPlan = await _planRepository.GetAsync(newPlanId);
var proratedFraction = subscription.Period.ProratedFraction(DateOnly.FromDateTime(DateTime.UtcNow));
subscription.ChangePlan(newPlan, proratedFraction);
await _repository.SaveAsync(subscription);
// Direct call to Billing context -- THIS IS THE PROBLEM
await _billingService.GenerateProratedInvoice(
subscriptionId, oldPlan.Id, newPlan.Id, proratedFraction);
}// In BillingService.cs (Billing context)
public async Task HandleFailedPayment(PaymentId paymentId, InvoiceId invoiceId)
{
var invoice = await _invoiceRepository.GetAsync(invoiceId);
var attemptCount = await _paymentRepository.CountFailedAttempts(invoiceId);
if (attemptCount >= 3)
{
// Direct call to Subscriptions context -- THE OTHER DIRECTION
await _subscriptionService.SuspendSubscription(
invoice.SubscriptionId, "Payment failed after 3 attempts");
}
// ... dunning logic ...
}// In BillingService.cs (Billing context)
public async Task HandleFailedPayment(PaymentId paymentId, InvoiceId invoiceId)
{
var invoice = await _invoiceRepository.GetAsync(invoiceId);
var attemptCount = await _paymentRepository.CountFailedAttempts(invoiceId);
if (attemptCount >= 3)
{
// Direct call to Subscriptions context -- THE OTHER DIRECTION
await _subscriptionService.SuspendSubscription(
invoice.SubscriptionId, "Payment failed after 3 attempts");
}
// ... dunning logic ...
}Subscriptions depends on Billing. Billing depends on Subscriptions. The dependency graph has a cycle.
Red arrows. A cycle. The caller knows the callee's interface, parameter types, error handling conventions, and transaction boundaries. When Billing's GenerateProratedInvoice signature changes, the Subscriptions team must update their code. When Subscriptions changes how suspension works, the Billing team must update theirs. Both teams edit code in both contexts. The boundary is a suggestion, not a wall.
Direct method calls create five forms of coupling:
- Interface coupling -- the caller depends on the method signature
- Data coupling -- the caller must supply the exact parameter types
- Temporal coupling -- the caller blocks until the callee finishes
- Error coupling -- the caller must handle the callee's exceptions
- Transaction coupling -- both operations share the caller's transaction scope (or they don't, and now you have consistency questions)
Domain events eliminate all five.
Domain Events
A domain event is a record of something that happened in the domain, expressed in past tense using the ubiquitous language. Not a command ("create invoice"), not a request ("please generate invoice"), not a notification ("invoice is ready"). A fact. Something that already happened. Immutable. Irrevocable.
In C#, a domain event is an immutable record that inherits from a base DomainEvent type:
namespace SubscriptionHub.SharedKernel;
public abstract record DomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}namespace SubscriptionHub.SharedKernel;
public abstract record DomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}Events carry data. They do not carry behavior. They are facts, not actors. They describe what happened, not what should happen next. The producer raises the event. The producer does not know -- and does not care -- who listens.
Three rules govern domain events in this architecture:
Aggregates raise events. Not services, not controllers, not middleware. The aggregate is the authority on what happened in its consistency boundary. When a subscription changes plan, the
Subscriptionaggregate raisesPlanChangedEvent. The application service dispatches it.Events are past tense.
PlanChanged, notChangePlan.PaymentFailed, notFailPayment.InvoiceGenerated, notGenerateInvoice. The naming convention makes the direction of information flow obvious: events flow out from the source. Commands flow in to a handler.Handlers live in the consuming context.
GenerateProratedInvoiceHandlerlives in Billing, not in Subscriptions. The handler knows its own domain. It does not reference the producing context.
There is an important distinction between domain events and integration events. Domain events are in-process -- dispatched within the same application boundary, same transaction or immediately after. Integration events cross process boundaries -- published to a message bus, consumed by other services. For a monolith migration, we start with domain events. They are simpler, faster, and sufficient for breaking circular dependencies within a single deployment unit. Integration events come later, when you decompose into services.
Breaking the Cycle
Here is the transformation. The direct call becomes three independent pieces: an event, a handler, and a dispatcher. No piece knows about the others at compile time.
Before
// SubscriptionService calls BillingService directly
await _billingService.GenerateProratedInvoice(
subscriptionId, oldPlan.Id, newPlan.Id, proratedFraction);// SubscriptionService calls BillingService directly
await _billingService.GenerateProratedInvoice(
subscriptionId, oldPlan.Id, newPlan.Id, proratedFraction);One line. Simple. Obvious. And it welds two bounded contexts together.
After — The Event
The Subscription aggregate raises the event when its state changes:
namespace SubscriptionHub.Subscriptions.Domain.Events;
public sealed record PlanChangedEvent(
SubscriptionId SubscriptionId,
PlanId OldPlanId,
PlanId NewPlanId,
decimal ProratedFraction,
DateOnly EffectiveDate) : DomainEvent;namespace SubscriptionHub.Subscriptions.Domain.Events;
public sealed record PlanChangedEvent(
SubscriptionId SubscriptionId,
PlanId OldPlanId,
PlanId NewPlanId,
decimal ProratedFraction,
DateOnly EffectiveDate) : DomainEvent;And inside the aggregate:
public sealed class Subscription : AggregateRoot<SubscriptionId>
{
// ... fields, constructor ...
public Result ChangePlan(Plan newPlan, decimal proratedFraction)
{
if (_status != SubscriptionStatus.Active)
return Result.Failure("Cannot change plan on inactive subscription");
if (_plan.Id == newPlan.Id)
return Result.Failure("Already on this plan");
var oldPlanId = _plan.Id;
_plan = newPlan;
_price = newPlan.Price;
RaiseDomainEvent(new PlanChangedEvent(
SubscriptionId: Id,
OldPlanId: oldPlanId,
NewPlanId: newPlan.Id,
ProratedFraction: proratedFraction,
EffectiveDate: DateOnly.FromDateTime(DateTime.UtcNow)));
return Result.Success();
}
}public sealed class Subscription : AggregateRoot<SubscriptionId>
{
// ... fields, constructor ...
public Result ChangePlan(Plan newPlan, decimal proratedFraction)
{
if (_status != SubscriptionStatus.Active)
return Result.Failure("Cannot change plan on inactive subscription");
if (_plan.Id == newPlan.Id)
return Result.Failure("Already on this plan");
var oldPlanId = _plan.Id;
_plan = newPlan;
_price = newPlan.Price;
RaiseDomainEvent(new PlanChangedEvent(
SubscriptionId: Id,
OldPlanId: oldPlanId,
NewPlanId: newPlan.Id,
ProratedFraction: proratedFraction,
EffectiveDate: DateOnly.FromDateTime(DateTime.UtcNow)));
return Result.Success();
}
}The aggregate knows that a plan changed. It does not know what happens next. It does not know that Billing exists. It records the fact and moves on.
After — The Handler
The handler lives in the Billing context. It reacts to PlanChangedEvent and creates an invoice:
namespace SubscriptionHub.Billing.Application.EventHandlers;
public sealed class GenerateProratedInvoiceHandler
: IDomainEventHandler<PlanChangedEvent>
{
private readonly IInvoiceRepository _invoiceRepository;
private readonly IPlanPricingService _pricingService;
private readonly ILogger<GenerateProratedInvoiceHandler> _logger;
public GenerateProratedInvoiceHandler(
IInvoiceRepository invoiceRepository,
IPlanPricingService pricingService,
ILogger<GenerateProratedInvoiceHandler> logger)
{
_invoiceRepository = invoiceRepository;
_pricingService = pricingService;
_logger = logger;
}
public async Task HandleAsync(
PlanChangedEvent @event, CancellationToken ct)
{
var pricing = await _pricingService.CalculateProration(
@event.OldPlanId, @event.NewPlanId, @event.ProratedFraction);
var invoice = Invoice.CreateProrated(
subscriptionId: @event.SubscriptionId,
lineItems: pricing.LineItems,
effectiveDate: @event.EffectiveDate);
await _invoiceRepository.SaveAsync(invoice, ct);
_logger.LogInformation(
"Generated prorated invoice {InvoiceId} for subscription {SubscriptionId}. " +
"Plan change: {OldPlan} → {NewPlan}, fraction: {Fraction:P0}",
invoice.Id, @event.SubscriptionId,
@event.OldPlanId, @event.NewPlanId, @event.ProratedFraction);
}
}namespace SubscriptionHub.Billing.Application.EventHandlers;
public sealed class GenerateProratedInvoiceHandler
: IDomainEventHandler<PlanChangedEvent>
{
private readonly IInvoiceRepository _invoiceRepository;
private readonly IPlanPricingService _pricingService;
private readonly ILogger<GenerateProratedInvoiceHandler> _logger;
public GenerateProratedInvoiceHandler(
IInvoiceRepository invoiceRepository,
IPlanPricingService pricingService,
ILogger<GenerateProratedInvoiceHandler> logger)
{
_invoiceRepository = invoiceRepository;
_pricingService = pricingService;
_logger = logger;
}
public async Task HandleAsync(
PlanChangedEvent @event, CancellationToken ct)
{
var pricing = await _pricingService.CalculateProration(
@event.OldPlanId, @event.NewPlanId, @event.ProratedFraction);
var invoice = Invoice.CreateProrated(
subscriptionId: @event.SubscriptionId,
lineItems: pricing.LineItems,
effectiveDate: @event.EffectiveDate);
await _invoiceRepository.SaveAsync(invoice, ct);
_logger.LogInformation(
"Generated prorated invoice {InvoiceId} for subscription {SubscriptionId}. " +
"Plan change: {OldPlan} → {NewPlan}, fraction: {Fraction:P0}",
invoice.Id, @event.SubscriptionId,
@event.OldPlanId, @event.NewPlanId, @event.ProratedFraction);
}
}The handler knows Billing. It knows IInvoiceRepository, IPlanPricingService, Invoice. It does not know Subscription, SubscriptionService, or anything from the Subscriptions context. It receives a PlanChangedEvent -- a record defined in the SharedKernel or the Subscriptions.Domain.Events namespace -- and acts on it using only Billing concepts.
After — The Dispatcher
The application service dispatches events after saving the aggregate:
namespace SubscriptionHub.Subscriptions.Application.Commands;
public sealed class ChangePlanCommandHandler
{
private readonly ISubscriptionRepository _repository;
private readonly IPlanRepository _planRepository;
private readonly IDomainEventDispatcher _dispatcher;
public ChangePlanCommandHandler(
ISubscriptionRepository repository,
IPlanRepository planRepository,
IDomainEventDispatcher dispatcher)
{
_repository = repository;
_planRepository = planRepository;
_dispatcher = dispatcher;
}
public async Task<Result> HandleAsync(ChangePlanCommand command, CancellationToken ct)
{
var subscription = await _repository.GetAsync(command.SubscriptionId, ct);
var newPlan = await _planRepository.GetAsync(command.NewPlanId, ct);
var proratedFraction = subscription.Period
.ProratedFraction(DateOnly.FromDateTime(DateTime.UtcNow));
var result = subscription.ChangePlan(newPlan, proratedFraction);
if (result.IsFailure)
return result;
await _repository.SaveAsync(subscription, ct);
// Dispatch all domain events raised by the aggregate
await _dispatcher.DispatchAsync(subscription.DomainEvents, ct);
return Result.Success();
}
}namespace SubscriptionHub.Subscriptions.Application.Commands;
public sealed class ChangePlanCommandHandler
{
private readonly ISubscriptionRepository _repository;
private readonly IPlanRepository _planRepository;
private readonly IDomainEventDispatcher _dispatcher;
public ChangePlanCommandHandler(
ISubscriptionRepository repository,
IPlanRepository planRepository,
IDomainEventDispatcher dispatcher)
{
_repository = repository;
_planRepository = planRepository;
_dispatcher = dispatcher;
}
public async Task<Result> HandleAsync(ChangePlanCommand command, CancellationToken ct)
{
var subscription = await _repository.GetAsync(command.SubscriptionId, ct);
var newPlan = await _planRepository.GetAsync(command.NewPlanId, ct);
var proratedFraction = subscription.Period
.ProratedFraction(DateOnly.FromDateTime(DateTime.UtcNow));
var result = subscription.ChangePlan(newPlan, proratedFraction);
if (result.IsFailure)
return result;
await _repository.SaveAsync(subscription, ct);
// Dispatch all domain events raised by the aggregate
await _dispatcher.DispatchAsync(subscription.DomainEvents, ct);
return Result.Success();
}
}The dispatcher is a simple interface with a simple implementation:
public interface IDomainEventDispatcher
{
Task DispatchAsync(
IReadOnlyList<DomainEvent> events,
CancellationToken ct = default);
}
public sealed class InProcessEventDispatcher : IDomainEventDispatcher
{
private readonly IServiceProvider _serviceProvider;
public InProcessEventDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task DispatchAsync(
IReadOnlyList<DomainEvent> events, CancellationToken ct)
{
foreach (var @event in events)
{
var handlerType = typeof(IDomainEventHandler<>)
.MakeGenericType(@event.GetType());
var handlers = _serviceProvider.GetServices(handlerType);
foreach (var handler in handlers)
{
var method = handlerType.GetMethod("HandleAsync")!;
await (Task)method.Invoke(handler, [@event, ct])!;
}
}
}
}public interface IDomainEventDispatcher
{
Task DispatchAsync(
IReadOnlyList<DomainEvent> events,
CancellationToken ct = default);
}
public sealed class InProcessEventDispatcher : IDomainEventDispatcher
{
private readonly IServiceProvider _serviceProvider;
public InProcessEventDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task DispatchAsync(
IReadOnlyList<DomainEvent> events, CancellationToken ct)
{
foreach (var @event in events)
{
var handlerType = typeof(IDomainEventHandler<>)
.MakeGenericType(@event.GetType());
var handlers = _serviceProvider.GetServices(handlerType);
foreach (var handler in handlers)
{
var method = handlerType.GetMethod("HandleAsync")!;
await (Task)method.Invoke(handler, [@event, ct])!;
}
}
}
}If you use MediatR, this is INotification and INotificationHandler<T>. If you prefer to avoid the dependency, the dispatcher above is 25 lines and does the same thing. Either way, the wiring is in DI registration:
services.AddScoped<IDomainEventHandler<PlanChangedEvent>,
GenerateProratedInvoiceHandler>();services.AddScoped<IDomainEventHandler<PlanChangedEvent>,
GenerateProratedInvoiceHandler>();Now look at the dependency graph:
No arrow from Subscriptions to Billing. No arrow from Billing to Subscriptions. Both contexts depend on the SharedKernel (the green box in the middle). The cycle is broken. Subscriptions does not know Billing exists. Billing does not know Subscription's internals. Each team edits only their own code.
Event Catalog
SubscriptionHub's domain events, organized by producing context. Each event replaces a direct method call that coupled two contexts.
Subscriptions Produces
namespace SubscriptionHub.Subscriptions.Domain.Events;
/// <summary>
/// Raised when a new subscription is created and activated.
/// Replaces: BillingService.InitializeSubscriptionBilling(subscriptionId)
/// Subscribers: Billing (create first invoice), Notifications (welcome email)
/// </summary>
public sealed record SubscriptionCreatedEvent(
SubscriptionId SubscriptionId,
CustomerId CustomerId,
PlanId PlanId,
Money Price,
SubscriptionPeriod Period) : DomainEvent;
/// <summary>
/// Raised when a customer upgrades or downgrades their plan.
/// Replaces: BillingService.GenerateProratedInvoice(...)
/// Subscribers: Billing (prorated invoice), Notifications (plan change confirmation)
/// </summary>
public sealed record PlanChangedEvent(
SubscriptionId SubscriptionId,
PlanId OldPlanId,
PlanId NewPlanId,
decimal ProratedFraction,
DateOnly EffectiveDate) : DomainEvent;
/// <summary>
/// Raised when a customer cancels their subscription.
/// Replaces: BillingService.ProcessCancellationRefund(subscriptionId)
/// Subscribers: Billing (final invoice / refund), Notifications (cancellation email)
/// </summary>
public sealed record SubscriptionCancelledEvent(
SubscriptionId SubscriptionId,
CancellationReason Reason,
DateOnly EffectiveDate) : DomainEvent;
/// <summary>
/// Raised when a subscription is suspended due to payment failures or policy.
/// Replaces: NotificationService.SendSuspensionWarning(subscriptionId)
/// Subscribers: Notifications (suspension email), Analytics (churn signal)
/// </summary>
public sealed record SubscriptionSuspendedEvent(
SubscriptionId SubscriptionId,
SuspensionReason Reason) : DomainEvent;namespace SubscriptionHub.Subscriptions.Domain.Events;
/// <summary>
/// Raised when a new subscription is created and activated.
/// Replaces: BillingService.InitializeSubscriptionBilling(subscriptionId)
/// Subscribers: Billing (create first invoice), Notifications (welcome email)
/// </summary>
public sealed record SubscriptionCreatedEvent(
SubscriptionId SubscriptionId,
CustomerId CustomerId,
PlanId PlanId,
Money Price,
SubscriptionPeriod Period) : DomainEvent;
/// <summary>
/// Raised when a customer upgrades or downgrades their plan.
/// Replaces: BillingService.GenerateProratedInvoice(...)
/// Subscribers: Billing (prorated invoice), Notifications (plan change confirmation)
/// </summary>
public sealed record PlanChangedEvent(
SubscriptionId SubscriptionId,
PlanId OldPlanId,
PlanId NewPlanId,
decimal ProratedFraction,
DateOnly EffectiveDate) : DomainEvent;
/// <summary>
/// Raised when a customer cancels their subscription.
/// Replaces: BillingService.ProcessCancellationRefund(subscriptionId)
/// Subscribers: Billing (final invoice / refund), Notifications (cancellation email)
/// </summary>
public sealed record SubscriptionCancelledEvent(
SubscriptionId SubscriptionId,
CancellationReason Reason,
DateOnly EffectiveDate) : DomainEvent;
/// <summary>
/// Raised when a subscription is suspended due to payment failures or policy.
/// Replaces: NotificationService.SendSuspensionWarning(subscriptionId)
/// Subscribers: Notifications (suspension email), Analytics (churn signal)
/// </summary>
public sealed record SubscriptionSuspendedEvent(
SubscriptionId SubscriptionId,
SuspensionReason Reason) : DomainEvent;Four events. Four former direct calls eliminated. The SubscriptionCreatedEvent replaces a BillingService.InitializeSubscriptionBilling() call that the Subscriptions team had to invoke manually after creating a subscription. The SubscriptionCancelledEvent replaces a BillingService.ProcessCancellationRefund() call that was inlined in the controller. The SubscriptionSuspendedEvent replaces a NotificationService.SendSuspensionWarning() call that required a project reference from Subscriptions to Notifications.
Billing Produces
namespace SubscriptionHub.Billing.Domain.Events;
/// <summary>
/// Raised when an invoice is finalized and ready for payment.
/// Replaces: NotificationService.SendInvoiceNotification(invoiceId)
/// Subscribers: Notifications (invoice email with PDF)
/// </summary>
public sealed record InvoiceGeneratedEvent(
InvoiceId InvoiceId,
SubscriptionId SubscriptionId,
Money Total,
IReadOnlyList<LineItemSummary> LineItems) : DomainEvent;
public sealed record LineItemSummary(
string Description,
Money Amount,
int Quantity);
/// <summary>
/// Raised when a payment attempt succeeds.
/// Replaces: SubscriptionService.ActivateSubscription(subscriptionId)
/// Subscribers: Subscriptions (reactivate if past-due), Notifications (receipt),
/// Analytics (revenue event)
/// </summary>
public sealed record PaymentSucceededEvent(
PaymentId PaymentId,
InvoiceId InvoiceId,
Money Amount,
DateOnly PaidDate) : DomainEvent;
/// <summary>
/// Raised when a payment attempt fails.
/// Replaces: SubscriptionService.SuspendSubscription(subscriptionId, reason)
/// Subscribers: Subscriptions (mark past-due or suspend after 3 failures),
/// Notifications (payment failure email with retry info)
/// </summary>
public sealed record PaymentFailedEvent(
PaymentId PaymentId,
InvoiceId InvoiceId,
Money Amount,
string FailureReason,
int AttemptNumber) : DomainEvent;namespace SubscriptionHub.Billing.Domain.Events;
/// <summary>
/// Raised when an invoice is finalized and ready for payment.
/// Replaces: NotificationService.SendInvoiceNotification(invoiceId)
/// Subscribers: Notifications (invoice email with PDF)
/// </summary>
public sealed record InvoiceGeneratedEvent(
InvoiceId InvoiceId,
SubscriptionId SubscriptionId,
Money Total,
IReadOnlyList<LineItemSummary> LineItems) : DomainEvent;
public sealed record LineItemSummary(
string Description,
Money Amount,
int Quantity);
/// <summary>
/// Raised when a payment attempt succeeds.
/// Replaces: SubscriptionService.ActivateSubscription(subscriptionId)
/// Subscribers: Subscriptions (reactivate if past-due), Notifications (receipt),
/// Analytics (revenue event)
/// </summary>
public sealed record PaymentSucceededEvent(
PaymentId PaymentId,
InvoiceId InvoiceId,
Money Amount,
DateOnly PaidDate) : DomainEvent;
/// <summary>
/// Raised when a payment attempt fails.
/// Replaces: SubscriptionService.SuspendSubscription(subscriptionId, reason)
/// Subscribers: Subscriptions (mark past-due or suspend after 3 failures),
/// Notifications (payment failure email with retry info)
/// </summary>
public sealed record PaymentFailedEvent(
PaymentId PaymentId,
InvoiceId InvoiceId,
Money Amount,
string FailureReason,
int AttemptNumber) : DomainEvent;Three events from Billing. The most important is PaymentFailedEvent -- this is the other half of the circular dependency. Instead of Billing calling SubscriptionService.SuspendSubscription(), the Payment aggregate raises PaymentFailedEvent and the Subscriptions context handles it. The cycle breaks in both directions.
Notice that PaymentSucceededEvent also eliminates a dependency: Billing used to call SubscriptionService.ActivateSubscription() to reactivate a past-due subscription after successful payment. That direct call is gone. Subscriptions listens and decides.
The Full Event Flow
Here is the complete picture -- all four contexts, all events, all handlers:
Every arrow is a domain event. Every handler lives in the receiving context. No context references another context's domain directly. The events are the only coupling -- thin, immutable records that carry facts. If Notifications adds a new handler for PlanChangedEvent, the Subscriptions team does not know and does not need to know. If Analytics wants to start tracking PaymentFailedEvent, they register a handler. Zero changes in Billing.
This is the Open/Closed Principle applied to bounded context integration. The producing context is closed for modification, open for extension -- through events.
Event Handlers
Three handlers in full, showing how each context reacts to events from other contexts without referencing them.
GenerateProratedInvoiceHandler
This handler lives in the Billing context. It creates a prorated invoice when a customer changes plan. The full implementation was shown above; here is the critical insight: the handler uses only Billing types. Invoice, IInvoiceRepository, IPlanPricingService -- all from SubscriptionHub.Billing.Domain. The PlanChangedEvent is the only type from outside, and it lives in the SharedKernel or the Subscriptions events namespace.
SuspendOnPaymentFailureHandler
This handler lives in the Subscriptions context. It reacts to PaymentFailedEvent from Billing:
namespace SubscriptionHub.Subscriptions.Application.EventHandlers;
public sealed class SuspendOnPaymentFailureHandler
: IDomainEventHandler<PaymentFailedEvent>
{
private readonly ISubscriptionRepository _repository;
private readonly ILogger<SuspendOnPaymentFailureHandler> _logger;
private const int MaxRetries = 3;
public SuspendOnPaymentFailureHandler(
ISubscriptionRepository repository,
ILogger<SuspendOnPaymentFailureHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task HandleAsync(
PaymentFailedEvent @event, CancellationToken ct)
{
// Find the subscription through the invoice relationship
var subscription = await _repository
.GetByInvoiceIdAsync(@event.InvoiceId, ct);
if (subscription is null)
{
_logger.LogWarning(
"No subscription found for invoice {InvoiceId}", @event.InvoiceId);
return;
}
if (@event.AttemptNumber >= MaxRetries)
{
subscription.Suspend(
SuspensionReason.PaymentFailure(
$"Payment failed after {MaxRetries} attempts: {@event.FailureReason}"));
_logger.LogWarning(
"Subscription {SubscriptionId} suspended after {Attempts} payment failures. " +
"Last failure: {Reason}",
subscription.Id, @event.AttemptNumber, @event.FailureReason);
}
else
{
subscription.MarkPastDue();
_logger.LogInformation(
"Subscription {SubscriptionId} marked past-due. " +
"Payment attempt {Attempt}/{Max} failed: {Reason}",
subscription.Id, @event.AttemptNumber, MaxRetries, @event.FailureReason);
}
await _repository.SaveAsync(subscription, ct);
}
}namespace SubscriptionHub.Subscriptions.Application.EventHandlers;
public sealed class SuspendOnPaymentFailureHandler
: IDomainEventHandler<PaymentFailedEvent>
{
private readonly ISubscriptionRepository _repository;
private readonly ILogger<SuspendOnPaymentFailureHandler> _logger;
private const int MaxRetries = 3;
public SuspendOnPaymentFailureHandler(
ISubscriptionRepository repository,
ILogger<SuspendOnPaymentFailureHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task HandleAsync(
PaymentFailedEvent @event, CancellationToken ct)
{
// Find the subscription through the invoice relationship
var subscription = await _repository
.GetByInvoiceIdAsync(@event.InvoiceId, ct);
if (subscription is null)
{
_logger.LogWarning(
"No subscription found for invoice {InvoiceId}", @event.InvoiceId);
return;
}
if (@event.AttemptNumber >= MaxRetries)
{
subscription.Suspend(
SuspensionReason.PaymentFailure(
$"Payment failed after {MaxRetries} attempts: {@event.FailureReason}"));
_logger.LogWarning(
"Subscription {SubscriptionId} suspended after {Attempts} payment failures. " +
"Last failure: {Reason}",
subscription.Id, @event.AttemptNumber, @event.FailureReason);
}
else
{
subscription.MarkPastDue();
_logger.LogInformation(
"Subscription {SubscriptionId} marked past-due. " +
"Payment attempt {Attempt}/{Max} failed: {Reason}",
subscription.Id, @event.AttemptNumber, MaxRetries, @event.FailureReason);
}
await _repository.SaveAsync(subscription, ct);
}
}This is the handler that replaces the direct BillingService → SubscriptionService.SuspendSubscription() call from the old code. Compare the coupling: before, Billing knew how to suspend a subscription -- it called SuspendSubscription(subscriptionId, reason) with specific parameters. Now, Billing does not know subscriptions can be suspended. Billing raises PaymentFailedEvent with factual data (which payment, which invoice, which attempt number, what went wrong), and Subscriptions decides what to do with that fact. The business logic for "three strikes means suspension" lives in the Subscriptions context, where it belongs. Billing should not be making decisions about subscription lifecycle.
ReactivateSubscriptionHandler
This handler also lives in Subscriptions. It reacts to PaymentSucceededEvent from Billing:
namespace SubscriptionHub.Subscriptions.Application.EventHandlers;
public sealed class ReactivateSubscriptionHandler
: IDomainEventHandler<PaymentSucceededEvent>
{
private readonly ISubscriptionRepository _repository;
private readonly ILogger<ReactivateSubscriptionHandler> _logger;
public ReactivateSubscriptionHandler(
ISubscriptionRepository repository,
ILogger<ReactivateSubscriptionHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task HandleAsync(
PaymentSucceededEvent @event, CancellationToken ct)
{
var subscription = await _repository
.GetByInvoiceIdAsync(@event.InvoiceId, ct);
if (subscription is null) return;
if (subscription.Status is SubscriptionStatus.PastDue
or SubscriptionStatus.Suspended)
{
subscription.Reactivate();
await _repository.SaveAsync(subscription, ct);
_logger.LogInformation(
"Subscription {SubscriptionId} reactivated after payment {PaymentId}",
subscription.Id, @event.PaymentId);
}
}
}namespace SubscriptionHub.Subscriptions.Application.EventHandlers;
public sealed class ReactivateSubscriptionHandler
: IDomainEventHandler<PaymentSucceededEvent>
{
private readonly ISubscriptionRepository _repository;
private readonly ILogger<ReactivateSubscriptionHandler> _logger;
public ReactivateSubscriptionHandler(
ISubscriptionRepository repository,
ILogger<ReactivateSubscriptionHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task HandleAsync(
PaymentSucceededEvent @event, CancellationToken ct)
{
var subscription = await _repository
.GetByInvoiceIdAsync(@event.InvoiceId, ct);
if (subscription is null) return;
if (subscription.Status is SubscriptionStatus.PastDue
or SubscriptionStatus.Suspended)
{
subscription.Reactivate();
await _repository.SaveAsync(subscription, ct);
_logger.LogInformation(
"Subscription {SubscriptionId} reactivated after payment {PaymentId}",
subscription.Id, @event.PaymentId);
}
}
}Two handlers in the Subscriptions context reacting to Billing events. Neither handler references any Billing type. The events are the contract. If Billing changes how it processes payments internally -- different retry logic, different payment provider, different invoice model -- these handlers are unaffected. The event record is the only coupling point, and records are stable by design (you add fields, you never remove them).
UpdateAnalyticsHandler
A simpler handler, living in the Analytics context:
namespace SubscriptionHub.Analytics.Application.EventHandlers;
public sealed class RecordRevenueHandler
: IDomainEventHandler<PaymentSucceededEvent>
{
private readonly IRevenueStore _revenueStore;
public RecordRevenueHandler(IRevenueStore revenueStore)
{
_revenueStore = revenueStore;
}
public async Task HandleAsync(
PaymentSucceededEvent @event, CancellationToken ct)
{
await _revenueStore.RecordAsync(new RevenueEntry(
Date: @event.PaidDate,
Amount: @event.Amount,
InvoiceId: @event.InvoiceId,
PaymentId: @event.PaymentId), ct);
}
}namespace SubscriptionHub.Analytics.Application.EventHandlers;
public sealed class RecordRevenueHandler
: IDomainEventHandler<PaymentSucceededEvent>
{
private readonly IRevenueStore _revenueStore;
public RecordRevenueHandler(IRevenueStore revenueStore)
{
_revenueStore = revenueStore;
}
public async Task HandleAsync(
PaymentSucceededEvent @event, CancellationToken ct)
{
await _revenueStore.RecordAsync(new RevenueEntry(
Date: @event.PaidDate,
Amount: @event.Amount,
InvoiceId: @event.InvoiceId,
PaymentId: @event.PaymentId), ct);
}
}Fifteen lines. No reference to Billing or Subscriptions. The Analytics team registers this handler and starts getting revenue data without asking any other team to change anything. This is what event-driven integration looks like in practice -- each context can start listening to events that interest it, independently, without coordination.
Subscription Lifecycle
With domain events driving state transitions, the subscription lifecycle becomes a state machine. Each state transition is triggered by a command or an event, and each transition may raise further events.
The states:
- Draft -- subscription created but not yet activated (awaiting initial payment)
- Active -- healthy subscription, payments current
- PastDue -- one or two payment failures, grace period active
- Suspended -- three payment failures, service restricted
- Cancelled -- customer or system terminated the subscription
Each transition in this diagram maps to either a command handler or an event handler:
| From | To | Trigger | Handler |
|---|---|---|---|
| Draft | Active | PaymentSucceededEvent |
ReactivateSubscriptionHandler |
| Active | Active | ChangePlan command |
ChangePlanCommandHandler |
| Active | PastDue | PaymentFailedEvent (1-2) |
SuspendOnPaymentFailureHandler |
| Active | Cancelled | Cancel command |
CancelSubscriptionCommandHandler |
| PastDue | Active | PaymentSucceededEvent |
ReactivateSubscriptionHandler |
| PastDue | Suspended | PaymentFailedEvent (3+) |
SuspendOnPaymentFailureHandler |
| Suspended | Active | PaymentSucceededEvent |
ReactivateSubscriptionHandler |
| Suspended | Cancelled | Grace period expired | ExpiredSubscriptionJob (background) |
The aggregate enforces which transitions are valid:
public sealed class Subscription : AggregateRoot<SubscriptionId>
{
private SubscriptionStatus _status;
public Result Suspend(SuspensionReason reason)
{
if (_status is not (SubscriptionStatus.Active or SubscriptionStatus.PastDue))
return Result.Failure(
$"Cannot suspend subscription in {_status} state");
_status = SubscriptionStatus.Suspended;
RaiseDomainEvent(new SubscriptionSuspendedEvent(Id, reason));
return Result.Success();
}
public Result MarkPastDue()
{
if (_status != SubscriptionStatus.Active)
return Result.Failure(
$"Cannot mark subscription as past-due in {_status} state");
_status = SubscriptionStatus.PastDue;
return Result.Success();
}
public Result Reactivate()
{
if (_status is not (SubscriptionStatus.PastDue
or SubscriptionStatus.Suspended
or SubscriptionStatus.Draft))
return Result.Failure(
$"Cannot reactivate subscription in {_status} state");
_status = SubscriptionStatus.Active;
return Result.Success();
}
public Result Cancel(CancellationReason reason)
{
if (_status == SubscriptionStatus.Cancelled)
return Result.Failure("Subscription is already cancelled");
var effectiveDate = _status == SubscriptionStatus.Draft
? DateOnly.FromDateTime(DateTime.UtcNow)
: _period.EndDate;
_status = SubscriptionStatus.Cancelled;
RaiseDomainEvent(new SubscriptionCancelledEvent(Id, reason, effectiveDate));
return Result.Success();
}
}public sealed class Subscription : AggregateRoot<SubscriptionId>
{
private SubscriptionStatus _status;
public Result Suspend(SuspensionReason reason)
{
if (_status is not (SubscriptionStatus.Active or SubscriptionStatus.PastDue))
return Result.Failure(
$"Cannot suspend subscription in {_status} state");
_status = SubscriptionStatus.Suspended;
RaiseDomainEvent(new SubscriptionSuspendedEvent(Id, reason));
return Result.Success();
}
public Result MarkPastDue()
{
if (_status != SubscriptionStatus.Active)
return Result.Failure(
$"Cannot mark subscription as past-due in {_status} state");
_status = SubscriptionStatus.PastDue;
return Result.Success();
}
public Result Reactivate()
{
if (_status is not (SubscriptionStatus.PastDue
or SubscriptionStatus.Suspended
or SubscriptionStatus.Draft))
return Result.Failure(
$"Cannot reactivate subscription in {_status} state");
_status = SubscriptionStatus.Active;
return Result.Success();
}
public Result Cancel(CancellationReason reason)
{
if (_status == SubscriptionStatus.Cancelled)
return Result.Failure("Subscription is already cancelled");
var effectiveDate = _status == SubscriptionStatus.Draft
? DateOnly.FromDateTime(DateTime.UtcNow)
: _period.EndDate;
_status = SubscriptionStatus.Cancelled;
RaiseDomainEvent(new SubscriptionCancelledEvent(Id, reason, effectiveDate));
return Result.Success();
}
}Every method guards its transitions. Suspend() only works from Active or PastDue. MarkPastDue() only works from Active. Reactivate() works from PastDue, Suspended, or Draft. Cancel() works from any non-cancelled state. Invalid transitions return Result.Failure(), not exceptions. The aggregate is the single source of truth for which state transitions are legal.
For a deeper treatment of typed state machines in C# -- including compile-time guarantees and state-specific behavior -- see Finite State Machine.
Transactional Outbox
Domain events within a single bounded context are straightforward: the aggregate changes and its events are dispatched in the same request pipeline. But when events cross context boundaries, a question arises: what happens if the aggregate saves successfully but the event dispatch fails?
Consider ChangePlan:
- The
Subscriptionaggregate updates its plan and raisesPlanChangedEvent - The repository saves the aggregate to the Subscriptions database
- The dispatcher sends
PlanChangedEventtoGenerateProratedInvoiceHandler - The handler creates an
Invoicein the Billing database
What if step 3 fails? The subscription has a new plan, but no prorated invoice was created. The customer gets the new plan without paying the difference. What if step 4 fails? Same problem. The event was dispatched but the handler threw an exception.
This is the dual-write problem. Two systems must be updated atomically, but they have separate transaction scopes. The solution is the Transactional Outbox.
How It Works
Instead of dispatching events immediately, the application service writes events to an outbox table in the same transaction as the aggregate:
public sealed class OutboxAwareEventDispatcher : IDomainEventDispatcher
{
private readonly AppDbContext _dbContext;
public OutboxAwareEventDispatcher(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task DispatchAsync(
IReadOnlyList<DomainEvent> events, CancellationToken ct)
{
foreach (var @event in events)
{
_dbContext.OutboxMessages.Add(new OutboxMessage
{
Id = @event.EventId,
Type = @event.GetType().AssemblyQualifiedName!,
Payload = JsonSerializer.Serialize(@event, @event.GetType()),
OccurredAt = @event.OccurredAt,
ProcessedAt = null
});
}
// Events are saved in the SAME transaction as the aggregate.
// No separate SaveChanges call needed -- the command handler
// calls SaveChanges once, and both the aggregate state
// and the outbox messages are committed atomically.
}
}public sealed class OutboxAwareEventDispatcher : IDomainEventDispatcher
{
private readonly AppDbContext _dbContext;
public OutboxAwareEventDispatcher(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task DispatchAsync(
IReadOnlyList<DomainEvent> events, CancellationToken ct)
{
foreach (var @event in events)
{
_dbContext.OutboxMessages.Add(new OutboxMessage
{
Id = @event.EventId,
Type = @event.GetType().AssemblyQualifiedName!,
Payload = JsonSerializer.Serialize(@event, @event.GetType()),
OccurredAt = @event.OccurredAt,
ProcessedAt = null
});
}
// Events are saved in the SAME transaction as the aggregate.
// No separate SaveChanges call needed -- the command handler
// calls SaveChanges once, and both the aggregate state
// and the outbox messages are committed atomically.
}
}The outbox table:
public sealed class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTimeOffset OccurredAt { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
}public sealed class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTimeOffset OccurredAt { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
}A background worker processes unprocessed messages:
public sealed class OutboxProcessor : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxProcessor> _logger;
private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(5);
public OutboxProcessor(
IServiceScopeFactory scopeFactory,
ILogger<OutboxProcessor> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
var dispatcher = scope.ServiceProvider
.GetRequiredService<InProcessEventDispatcher>();
var messages = await dbContext.OutboxMessages
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.OccurredAt)
.Take(20)
.ToListAsync(ct);
foreach (var message in messages)
{
try
{
var eventType = Type.GetType(message.Type)!;
var @event = (DomainEvent)JsonSerializer
.Deserialize(message.Payload, eventType)!;
await dispatcher.DispatchAsync([@event], ct);
message.ProcessedAt = DateTimeOffset.UtcNow;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to process outbox message {MessageId} of type {Type}",
message.Id, message.Type);
// Message stays unprocessed -- will be retried next cycle
}
}
await dbContext.SaveChangesAsync(ct);
await Task.Delay(_pollingInterval, ct);
}
}
}public sealed class OutboxProcessor : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxProcessor> _logger;
private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(5);
public OutboxProcessor(
IServiceScopeFactory scopeFactory,
ILogger<OutboxProcessor> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
var dispatcher = scope.ServiceProvider
.GetRequiredService<InProcessEventDispatcher>();
var messages = await dbContext.OutboxMessages
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.OccurredAt)
.Take(20)
.ToListAsync(ct);
foreach (var message in messages)
{
try
{
var eventType = Type.GetType(message.Type)!;
var @event = (DomainEvent)JsonSerializer
.Deserialize(message.Payload, eventType)!;
await dispatcher.DispatchAsync([@event], ct);
message.ProcessedAt = DateTimeOffset.UtcNow;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to process outbox message {MessageId} of type {Type}",
message.Id, message.Type);
// Message stays unprocessed -- will be retried next cycle
}
}
await dbContext.SaveChangesAsync(ct);
await Task.Delay(_pollingInterval, ct);
}
}
}Consistency Guarantees
The outbox gives you exactly-once writing and at-least-once delivery:
The aggregate state change and the outbox message are written in a single database transaction. If the transaction fails, both roll back. If it succeeds, both are persisted. No dual-write risk.
The background worker polls for unprocessed messages and dispatches them. If the handler succeeds, the message is marked as processed. If the handler fails, the message stays unprocessed and is retried on the next cycle.
Because handlers may be invoked more than once (the worker crashed after dispatching but before marking the message as processed), handlers must be idempotent. The
GenerateProratedInvoiceHandlershould check whether a prorated invoice already exists for this subscription and effective date before creating a new one.
public async Task HandleAsync(PlanChangedEvent @event, CancellationToken ct)
{
// Idempotency check
var existing = await _invoiceRepository.FindProratedInvoiceAsync(
@event.SubscriptionId, @event.EffectiveDate, ct);
if (existing is not null)
{
_logger.LogInformation(
"Prorated invoice {InvoiceId} already exists for subscription {Sub} " +
"on {Date}. Skipping.",
existing.Id, @event.SubscriptionId, @event.EffectiveDate);
return;
}
// ... create invoice as before ...
}public async Task HandleAsync(PlanChangedEvent @event, CancellationToken ct)
{
// Idempotency check
var existing = await _invoiceRepository.FindProratedInvoiceAsync(
@event.SubscriptionId, @event.EffectiveDate, ct);
if (existing is not null)
{
_logger.LogInformation(
"Prorated invoice {InvoiceId} already exists for subscription {Sub} " +
"on {Date}. Skipping.",
existing.Id, @event.SubscriptionId, @event.EffectiveDate);
return;
}
// ... create invoice as before ...
}For an in-depth treatment of the Transactional Outbox pattern -- including EF Core interceptors that automatically capture domain events, message deduplication, and ordering guarantees -- see Transactional Outbox Pattern.
Within vs. Across Contexts
Not every event needs the outbox. The rule is simple:
Same context (aggregate + handler share a DbContext): dispatch synchronously after
SaveChanges. The handler runs in the same request. If it fails, the whole operation fails. Strong consistency.Different contexts (aggregate and handler have different DbContexts): use the outbox. The aggregate's state change is committed atomically with the outbox message. The handler runs asynchronously. Eventual consistency.
For SubscriptionHub, this means:
| Event | Producer | Consumer | Consistency |
|---|---|---|---|
PlanChangedEvent |
Subscriptions | Billing | Eventual (outbox) |
PlanChangedEvent |
Subscriptions | Notifications | Eventual (outbox) |
PaymentFailedEvent |
Billing | Subscriptions | Eventual (outbox) |
PaymentSucceededEvent |
Billing | Subscriptions | Eventual (outbox) |
PaymentSucceededEvent |
Billing | Analytics | Eventual (outbox) |
InvoiceGeneratedEvent |
Billing | Notifications | Eventual (outbox) |
SubscriptionSuspendedEvent |
Subscriptions | Notifications | Eventual (outbox) |
Every cross-context event uses the outbox. Every handler is idempotent. The system is eventually consistent across contexts and strongly consistent within them. This is not a compromise -- it is the correct model. Billing does not need to be atomically consistent with Subscriptions. A prorated invoice can be generated 5 seconds after the plan change. A suspension can happen 5 seconds after the third payment failure. The business accepts this. The business has always accepted this -- the old direct calls just hid the latency behind synchronous blocking.
Wrapping Up
All six phases are complete.
Phase 1 (Part V): Characterization tests captured the existing behavior. They are still green. They have been green through every refactoring.
Phase 2 (Part VI): Bounded context libraries gave each context its own .csproj with enforced dependency rules. The compiler prevents cross-context imports.
Phase 3 (Part VII): ACLs formalized the crossings. Stripe types do not leak into the domain. Notifications have their own read models. The Tax API is behind an interface.
Phase 4 (Part VIII): Value Objects eliminated primitive obsession. Money, SubscriptionPeriod, EmailAddress -- types that enforce their own invariants. Three proration implementations collapsed to one method on SubscriptionPeriod.
Phase 5 (Part IX): Aggregates enforce invariants. Subscription, Invoice, Payment -- rich domain models with typed IDs, private setters, Result returns, and domain events. The Strangler Fig ACL lets old and new code coexist with feature flag rollback.
Phase 6 (this part): Domain events broke the last remaining coupling -- the circular dependency between Subscriptions and Billing. Events are facts, not commands. Handlers live in the consuming context. The outbox guarantees reliable delivery. The subscription lifecycle is a state machine with transitions driven by events.
The domain tests prove correctness. The integration tests verify wiring. The characterization tests confirm we did not break existing behavior. Events decouple contexts.
In Part XI, we look at the final state -- the before and after across every dimension. The comparison table. The lessons learned. And the bridge to code generation: [Aggregate] and [AggregateRoot] attributes that make the compiler enforce DDD boundaries permanently.