← Back to writing

Domain Events and Event-Driven Workflows

ddddomain-eventsevent-drivensoftware-architecture

This is the fifth deep-dive in a series on Domain-Driven Design. It builds on the concepts introduced in Domain-Driven Design: A Practical Overview.

When a board session completes in StormBoard, several things need to happen. The board’s status updates. An AI analysis might trigger. The dashboard refreshes. Participants get notified.

You could wire all of that into the CompleteBoard handler: call the analysis engine, send SignalR messages, update dashboard counters. It would work. And then when you add backlog generation, you’d modify the handler again. And again for export functionality. And again for audit logging.

This is the coupling that domain events solve. Instead of one method knowing about every downstream consequence, the aggregate declares that something happened. Other parts of the system react.


What a domain event is

A domain event is a record of something that happened in the domain. Past tense. Immutable. It carries the data that downstream consumers need to react.

The interface is minimal:

public interface IDomainEvent
{
    DateTime OccurredOnUtc { get; }
}

A concrete event is a simple, immutable object:

public sealed record BoardCompleted(
    Guid BoardId,
    DateTime OccurredOnUtc) : IDomainEvent;

That’s it. No behavior, no dependencies, no framework. A domain event is a fact about the past.


Raising events from aggregates

Aggregates raise domain events when something meaningful happens. The AggregateRoot<TId> base class provides the infrastructure:

public abstract class AggregateRoot<TId> : Entity<TId>, IAggregateRoot
    where TId : notnull
{
    private readonly List<IDomainEvent> _domainEvents = [];

    public IReadOnlyCollection<IDomainEvent> DomainEvents
        => _domainEvents.AsReadOnly();

    protected void RaiseDomainEvent(IDomainEvent domainEvent)
    {
        ArgumentNullException.ThrowIfNull(domainEvent);
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents() => _domainEvents.Clear();
}

Events accumulate in a private list. They’re raised during domain operations but not dispatched yet. They’re collected until the aggregate is persisted. This is important. If you dispatch events during the operation, and the operation later fails and rolls back, the events have already been sent. Collecting first, dispatching after successful persistence, avoids that problem.

A domain operation raises the event as part of the state change:

public void Complete()
{
    if (Status == BoardStatus.Completed)
        throw new DomainException("Board is already completed.");

    Status = BoardStatus.Completed;
    RaiseDomainEvent(new BoardCompleted(Id.Value, DateTime.UtcNow));
}

The state change and the event are paired. You can’t have one without the other. If the board transitions to Completed, the event is raised. If the transition fails, no event exists.


When to raise events

Not every state change deserves an event. Raise events when:

  • Other aggregates need to react. An analysis should start when a board completes.
  • External systems need notification. Participants should see a real-time update.
  • You need an audit trail. Something happened that the business cares about.
  • The event has a name in the ubiquitous language. If domain experts talk about “completing a session,” that’s a domain event.

Don’t raise events for internal bookkeeping. Updating a timestamp or incrementing a counter doesn’t need to be broadcast.


Dispatching after persistence

The key design decision is when events get dispatched. There are two approaches. One of them is wrong for most systems.

Dispatching during SaveChanges. Events are dispatched inside the database transaction. Handlers run within the same transaction. If a handler fails, the entire transaction rolls back, including the original state change. This gives you transactional consistency but couples your persistence to every event handler.

Dispatching after SaveChanges. Events are dispatched after the transaction commits. The state change is durable. Handlers run independently. If a handler fails, the original operation still succeeded.

The second approach is almost always what you want. Here’s a SaveChangesInterceptor that implements it:

public class DomainEventDispatchInterceptor : SaveChangesInterceptor
{
    private readonly Func<IDomainEvent, CancellationToken, Task> _dispatch;

    public DomainEventDispatchInterceptor(
        Func<IDomainEvent, CancellationToken, Task> dispatch)
    {
        _dispatch = dispatch;
    }

    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is not DbContext context)
            return result;

        var aggregates = context.ChangeTracker
            .Entries<IAggregateRoot>()
            .Select(e => e.Entity)
            .Where(e => e.DomainEvents.Count > 0)
            .ToList();

        var events = aggregates
            .SelectMany(a => a.DomainEvents)
            .ToList();

        foreach (var aggregate in aggregates)
            aggregate.ClearDomainEvents();

        foreach (var domainEvent in events)
            await _dispatch(domainEvent, cancellationToken);

        return result;
    }
}

After SaveChangesAsync succeeds, the interceptor scans the change tracker for aggregate roots with pending events, collects them, clears them from the aggregates, and dispatches each one. The aggregate doesn’t know who’s listening. The persistence layer doesn’t know what the events mean. The dispatch function is injected. It could publish to an in-process mediator, a message bus, or both.


Domain events vs. integration events

This distinction matters and is often overlooked.

Domain events are internal to a bounded context. They use domain types: BoardId, AnalysisStatus, strongly-typed IDs. They are dispatched in-process, typically through a mediator or event dispatcher within the same application. They carry domain data.

Integration events cross bounded context boundaries. They use primitive types: Guid, string, int. They are dispatched through a message broker: RabbitMQ, Azure Service Bus, Kafka. They carry the minimum data needed for the consumer to react, because the consumer shouldn’t depend on another context’s domain model.

In a monolith like StormBoard, domain events are sufficient. Everything runs in the same process, so in-process dispatch works. But the separation still matters conceptually. If you later extract a bounded context into a separate service, the domain events inside that context stay the same. You only add integration events at the boundary.

A domain event:

public sealed record AnalysisCompleted(
    AnalysisId AnalysisId,
    BoardId BoardId,
    DateTime OccurredOnUtc) : IDomainEvent;

The equivalent integration event for a distributed system:

public sealed record AnalysisCompletedIntegrationEvent(
    Guid AnalysisId,
    Guid BoardId,
    DateTime OccurredOnUtc);

The domain event uses AnalysisId and BoardId, strongly-typed IDs from the domain. The integration event uses raw Guid values, with no coupling to the domain model. An anti-corruption layer at the boundary translates between them.


Event-driven workflows

Domain events enable workflows that span multiple operations without tight coupling. Each step in the workflow reacts to the previous step’s event.

Consider the workflow that triggers when a board session completes:

  1. Facilitator completes the session → BoardCompleted event
  2. Analysis handler reacts → runs DDD analysis → AnalysisCompleted event
  3. Dashboard handler reacts → refreshes aggregate statistics
  4. Notification handler reacts → pushes real-time update to participants

Each handler knows only about the event it listens to. The analysis handler doesn’t know about dashboards. The notification handler doesn’t know about analysis. Adding a new step, like generating a PDF export, means adding a new handler. No existing code changes.

This is the open-closed principle at the architectural level. The workflow is open for extension (add new handlers) and closed for modification (existing handlers don’t change).


Event ordering and idempotency

Two concerns that matter in production.

Ordering. In-process dispatch processes events in the order they were raised. If your aggregate raises PhaseAdvanced and then StickyTypeRestrictionsChanged in the same operation, handlers see them in that order. In a distributed system with message brokers, ordering is harder. You may need to design handlers that work regardless of order.

Idempotency. If an event is dispatched twice (due to a retry, a race condition, or at-least-once delivery), the handler should produce the same result. For a command like “run analysis,” this means checking whether an analysis already exists for that board before starting a new one. For a notification, it might mean deduplicating by event ID. Design for at-least-once delivery from the start, even in a monolith. It’s the easiest time to get it right.


Common mistakes

Raising events that no one handles. If you raise StickyTextUpdated but nothing ever subscribes to it, you’ve added complexity with no benefit. Raise events when there’s a consumer, or when the event is part of the ubiquitous language and you expect consumers in the future.

Putting logic in the event. Events are data, not behavior. A BoardCompleted event should carry BoardId and a timestamp, not a method that runs the analysis. The handler has the logic; the event has the data.

Dispatching before persistence. If you dispatch events before the transaction commits and the transaction rolls back, you’ve notified the system about something that didn’t happen. Always dispatch after successful persistence.

Using events for synchronous queries. If handler A raises an event so handler B can look something up and return the result, that’s not an event. That’s a query. Events are fire-and-forget. If you need a return value, call a method.

Leaking domain events across context boundaries. Sending a domain event with BoardId (a strongly-typed ID) to another bounded context creates a dependency on the Board context’s domain model. Use integration events with primitive types at the boundary.


Domain events are how aggregates communicate without coupling. The aggregate declares what happened. The infrastructure decides when to dispatch. The handlers decide what to do about it. Each concern is separated, and the system grows by adding handlers, not by modifying the aggregate.

The next post covers repositories and persistence boundaries, including how aggregates are loaded and saved and why the persistence model should be separate from the domain model.

Found this useful?

If this post helped you, consider buying me a coffee.

Buy me a coffee

Comments