Skip to content

Domain Event Flow

How do you propagate events externally while maintaining domain layer purity? When an order is cancelled, inventory must be restored and payment refunded. But if the Aggregate directly calls other services, the domain layer becomes dependent on infrastructure. This chapter builds the complete flow of creating events inside the Aggregate, tracking through Repository, and publishing after SaveChanges.


After completing this chapter, you will be able to:

  1. Create events inside the Aggregate with AggregateRoot.AddDomainEvent()
  2. Explain the mechanism by which Repository tracks Aggregates with IDomainEventCollector
  3. Implement each stage of the event lifecycle (creation -> tracking -> publishing -> cleanup)
  4. Explain the roles of DomainEvent base properties (EventId, OccurredAt, CorrelationId)

Events are created inside the Aggregate, collected through the Repository, and published after transaction commit. Examine each stage in order.

1. Aggregate.Create() / UpdatePrice()
└── AddDomainEvent(new XxxEvent(...))
2. Repository.Create(aggregate)
└── eventCollector.Track(aggregate)
3. After SaveChanges completes
└── eventPublisher.PublishTrackedEvents()
├── Iterate aggregate.DomainEvents
├── Mediator.Publish(event)
└── aggregate.ClearDomainEvents()

When the Aggregate’s state changes, that fact is recorded as an event. AddDomainEvent() is a protected method of AggregateRoot<TId>, so events can only be created inside the Aggregate.

public static Product Create(string name, decimal price)
{
var product = new Product(ProductId.New(), name, price);
product.AddDomainEvent(new ProductCreatedEvent(
product.Id.ToString(), product.Name, product.Price));
return product;
}

When Repository saves an Aggregate, it registers that Aggregate with the Collector. The Collector is registered with Scoped lifetime, so all Repositories within a single request share the same Collector.

public interface IDomainEventCollector
{
void Track(IHasDomainEvents aggregate);
void TrackRange(IEnumerable<IHasDomainEvents> aggregates);
IReadOnlyList<IHasDomainEvents> GetTrackedAggregates();
}

You can verify the flow from event creation to collection in the files below.

FileDescription
ProductId.csUlid-based Product identifier
Product.csAggregateRoot + product entity that generates events
ProductCreatedEvent.csProduct creation domain event
ProductPriceChangedEvent.csPrice change domain event
SimpleDomainEventCollector.csIDomainEventCollector implementation
Program.csEvent flow demo

A summary of the core components of domain event flow.

ConceptDescription
DomainEventBase domain event record (includes EventId, OccurredAt)
AddDomainEvent()AggregateRoot’s protected method for event registration
ClearDomainEvents()Cleanup after event publishing
IDomainEventCollectorCollector for Repository to track Aggregates
IHasDomainEventsRead-only marker for Aggregates with events

A: In the UsecaseTransactionPipeline, after SaveChanges succeeds and the transaction is committed, IDomainEventPublisher.PublishTrackedEvents() is called to publish events.

A: DomainEventPublisher automatically calls it after publishing events. There’s no need to call it directly from the Usecase.

Q3: What happens if the same Aggregate is tracked multiple times?

Section titled “Q3: What happens if the same Aggregate is tracked multiple times?”

A: ReferenceEqualityComparer is used, so the same instance is tracked only once. Duplicate Track calls are ignored.


We’ve built the domain event collection and publishing flow. But what if every Usecase has to repeat SaveChanges and event publishing? In the next chapter, we’ll look at automating these cross-cutting concerns with a transaction pipeline.

-> Chapter 5: Transaction Pipeline