Skip to content

Domain Events

Domain events represent something meaningful that happened within a module's domain. They are published in-process via IMediator.Publish() and handled by one or more IDomainEventHandler<TEvent> implementations within the same application boundary.

IDomainEvent Interface

Every domain event implements IDomainEvent:

csharp
public interface IDomainEvent
{
    Guid Id { get; }
    DateTime OccurredOnUtc { get; }
}
PropertyTypeDescription
IdGuidUnique identifier for this event instance
OccurredOnUtcDateTimeUTC timestamp when the event was raised

Defining Domain Events

Domain events are typically defined in the Domain layer of a module. Use records for immutability and value equality:

csharp
public sealed record OrderPlacedEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal Total) : IDomainEvent
{
    public Guid Id { get; } = Guid.NewGuid();
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}
csharp
public sealed record ProductCreatedEvent(
    Guid ProductId,
    string Name,
    string Sku) : IDomainEvent
{
    public Guid Id { get; } = Guid.NewGuid();
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}
csharp
public sealed record OrderStatusChangedEvent(
    Guid OrderId,
    OrderStatus OldStatus,
    OrderStatus NewStatus) : IDomainEvent
{
    public Guid Id { get; } = Guid.NewGuid();
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}

Use a base record for convenience

Scaffolded solutions already ship one: DomainEvent in BuildingBlocks.Domain provides the Id and OccurredOnUtc defaults. If you are not using the scaffold, the same base record is a few lines:

csharp
public abstract record DomainEventBase : IDomainEvent
{
    public Guid Id { get; } = Guid.NewGuid();
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}

// Then your events become:
public sealed record OrderPlacedEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal Total) : DomainEventBase;

IDomainEventHandler Interface

Each handler implements IDomainEventHandler<TEvent>:

csharp
public interface IDomainEventHandler<in TEvent> where TEvent : IDomainEvent
{
    Task Handle(TEvent domainEvent, CancellationToken cancellationToken = default);
}

Handling Domain Events

Handlers are placed in the Application layer and are auto-registered by the source-generated AddModulusHandlers() extension method.

Example: Send a Notification When an Order Is Placed

csharp
public sealed class SendOrderConfirmationHandler
    : IDomainEventHandler<OrderPlacedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ICustomerRepository _customerRepository;

    public SendOrderConfirmationHandler(
        IEmailService emailService,
        ICustomerRepository customerRepository)
    {
        _emailService = emailService;
        _customerRepository = customerRepository;
    }

    public async Task Handle(
        OrderPlacedEvent domainEvent,
        CancellationToken cancellationToken)
    {
        var customer = await _customerRepository.GetByIdAsync(
            domainEvent.CustomerId, cancellationToken);

        if (customer is null) return;

        await _emailService.SendOrderConfirmationAsync(
            customer.Email,
            domainEvent.OrderId,
            domainEvent.Total,
            cancellationToken);
    }
}

Example: Update Inventory When an Order Is Placed

csharp
public sealed class ReserveInventoryHandler
    : IDomainEventHandler<OrderPlacedEvent>
{
    private readonly IInventoryService _inventoryService;

    public ReserveInventoryHandler(IInventoryService inventoryService)
    {
        _inventoryService = inventoryService;
    }

    public async Task Handle(
        OrderPlacedEvent domainEvent,
        CancellationToken cancellationToken)
    {
        await _inventoryService.ReserveStockAsync(
            domainEvent.OrderId, cancellationToken);
    }
}

Both handlers above will be invoked when OrderPlacedEvent is published. A single domain event can have any number of handlers.

Publishing Domain Events

Use IMediator.Publish() to dispatch a domain event to all registered handlers:

csharp
public sealed class PlaceOrderCommandHandler
    : ICommandHandler<PlaceOrderCommand, Guid>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IMediator _mediator;

    public PlaceOrderCommandHandler(
        IOrderRepository orderRepository,
        IMediator mediator)
    {
        _orderRepository = orderRepository;
        _mediator = mediator;
    }

    public async Task<Result<Guid>> Handle(
        PlaceOrderCommand command,
        CancellationToken cancellationToken)
    {
        var order = new Order(command.CustomerId, command.Items);

        await _orderRepository.AddAsync(order, cancellationToken);

        // Publish domain event -- all handlers will be invoked
        await _mediator.Publish(
            new OrderPlacedEvent(order.Id, command.CustomerId, order.Total),
            cancellationToken);

        return order.Id;
    }
}

Raising Events from Aggregate Roots

A common DDD pattern is to collect domain events on the aggregate root and publish them after persistence:

csharp
public abstract class AggregateRoot : Entity
{
    private readonly List<IDomainEvent> _domainEvents = [];

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

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

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}
csharp
public sealed class Order : AggregateRoot
{
    public void Place()
    {
        Status = OrderStatus.Placed;

        RaiseDomainEvent(new OrderPlacedEvent(Id, CustomerId, Total));
    }
}

Then publish collected events after saving:

csharp
public async Task<Result<Guid>> Handle(
    PlaceOrderCommand command,
    CancellationToken cancellationToken)
{
    var order = new Order(command.CustomerId, command.Items);
    order.Place();

    await _orderRepository.AddAsync(order, cancellationToken);
    await _unitOfWork.SaveChangesAsync(cancellationToken);

    // Publish all collected domain events
    foreach (var domainEvent in order.DomainEvents)
    {
        await _mediator.Publish(domainEvent, cancellationToken);
    }

    order.ClearDomainEvents();

    return order.Id;
}

Dispatch is by runtime type

Publish resolves handlers from the event's runtime type, not the compile-time type of the variable. Publishing through an IDomainEvent-typed variable -- as in the loop above, where order.DomainEvents is a collection of IDomainEvent -- still reaches every handler registered for the concrete event type. The scaffolded BaseDbContext relies on exactly this loop to dispatch collected events after SaveChangesAsync.

Error Handling Semantics

When mediator.Publish() is called:

  1. All registered handlers for the event's runtime type are resolved from the DI container.
  2. All handlers are invoked.
  3. If one or more handlers throw an exception, the exceptions are collected and thrown as an AggregateException after all handlers have been given a chance to run.

Handlers do not short-circuit each other

Unlike commands and queries, if one domain event handler fails, the remaining handlers still execute. All exceptions are aggregated. This ensures that a failure in one handler does not silently prevent other handlers from running.

Cancellation stops dispatch between handlers

Cancellation is the exception to the run-everything rule: if the CancellationToken is cancelled, dispatch stops between handlers -- handlers that have not started yet are skipped and the cancellation propagates as a cancellation (it is not folded into the AggregateException as a handler failure).

Publish Strategies

The error-handling semantics above describe the default. Publish actually supports three dispatch strategies, chosen once at registration time via MediatorOptions:

csharp
services.AddModulusMediator(options =>
{
    options.PublishStrategy = PublishStrategy.Parallel;
});
StrategyOrderingFailure handlingCancellation
Sequential (default)One handler at a time, in registration orderEvery handler still runs; all failures are collected into a single AggregateExceptionStops dispatch between handlers; handlers not yet started are skipped
ParallelEvery handler starts at once (Task.WhenAll)Every handler still runs to completion; all failures are collected into a single AggregateExceptionEvery handler is already running before cancellation can be observed -- an in-flight handler is not interrupted, only observed after every handler has settled
StopOnFirstFailureOne handler at a time, in registration orderThe first failing handler's exception is rethrown immediately, unwrapped (not an AggregateException); handlers registered after it never runStops dispatch between handlers, same as Sequential

Not calling the configure delegate -- or not adopting the delegate overload of AddModulusMediator at all -- keeps the pre-4.0 default: PublishStrategy.Sequential.

Choose Parallel deliberately

Parallel changes two things at once: handler ordering is no longer meaningful (they all start together), and a handler that mutates shared state your other handlers also touch is now racing them. Reach for Parallel only when your domain event handlers are independent of each other and of their execution order -- for example, several read-model projections that each own a disjoint slice of state.

StopOnFirstFailure trades completeness for fail-fast

Unlike Sequential/Parallel, StopOnFirstFailure does not attempt every handler -- it is closer to how a normal synchronous call chain behaves. Use it when a later handler depends on an earlier one having actually run (e.g. handler B assumes handler A already updated a read model).

Domain Events vs Integration Events

Modulus distinguishes between domain events and integration events:

AspectDomain EventsIntegration Events
ScopeIn-process, within a single module or across modules in the same processCross-module, potentially cross-service via a message bus
TransportIMediator.Publish() -- direct in-memory dispatchIMessageBus over the transport layer (InMemory, RabbitMQ, Azure Service Bus)
DeliverySynchronous (awaited), in the caller's process and scopeAsynchronous, eventual consistency
ContractIDomainEventIIntegrationEvent
CouplingHandlers reference domain types directlyHandlers reference shared integration contracts
Failure modelAggregateException thrown immediatelyRetry policies, dead-letter queues, outbox pattern

When to use domain events:

  • Reacting to something that happened within the same bounded context
  • Side effects that should happen immediately and in-process (e.g., updating a read model); publish before SaveChangesAsync if the side effect must join the same transaction -- the scaffolded BaseDbContext dispatches collected events after the save
  • In-process event-driven workflows

When to use integration events:

  • Communicating across module boundaries where loose coupling is required
  • Communicating across services in a distributed system
  • When you need reliable delivery with retries and outbox guarantees

Learn more about integration events

See the Messaging documentation for details on integration events, transports, and the outbox pattern.

Pipeline Behaviors and Domain Events

Domain events are not wrapped by pipeline behaviors. When you call mediator.Publish(), the event handlers are invoked directly -- no ValidationBehavior, LoggingBehavior, or other behaviors are applied.

If you need cross-cutting concerns for domain event handling, implement them directly in your handlers or use a decorator pattern.

See Also

Released under the MIT License.