Documentation Index
Fetch the complete documentation index at: https://mintlify.com/jordiaragonzaragoza/JordiAragonZaragoza.SharedKernel/llms.txt
Use this file to discover all available pages before exploring further.
Domain events capture the fact that something significant happened inside your domain. They are named in the past tense (OrderCreated, PaymentProcessed) and carry the minimal data needed to describe the change. Handlers — which live outside the aggregate — react to these events to trigger side effects such as sending emails, updating read models, or publishing integration events. SharedKernel wires this up through IDomainEvent, BaseDomainEvent, and EventsDispatcherService so that aggregates remain blissfully ignorant of the downstream consequences of their state changes.
IDomainEvent
IDomainEvent extends the shared IEvent contract (which itself extends MediatR’s INotification):
/// <summary>
/// The Event occurs within the problem domain (living inside a bounded context)
/// and is used to communicate a change in the state of the entity.
/// This is a private event part of Ubiquitous Language.
/// </summary>
public interface IDomainEvent : IEvent
{
Guid AggregateId { get; }
}
// Base IEvent contract
public interface IEvent : INotification
{
Guid Id { get; }
bool IsPublished { get; set; }
DateTimeOffset DateOccurredOnUtc { get; }
}
AggregateId ties the event back to the aggregate that raised it. Id is a unique event identity used for idempotency checks. DateOccurredOnUtc provides ordering when multiple events are dispatched in a single operation.
BaseDomainEvent
BaseDomainEvent is a C# abstract record class that gives a concrete implementation of IDomainEvent. Declare your own events by inheriting from it:
public abstract record class BaseDomainEvent(Guid AggregateId) : IDomainEvent
{
public Guid Id { get; protected init; } = Guid.NewGuid();
public bool IsPublished { get; set; }
public DateTimeOffset DateOccurredOnUtc { get; protected init; } = DateTimeOffset.UtcNow;
}
Using a record gives positional equality and a concise syntax for concrete events. Id and DateOccurredOnUtc are set automatically at construction time.
Defining a concrete event
public sealed record OrderCreatedEvent(Guid AggregateId, string CustomerName)
: BaseDomainEvent(AggregateId);
public sealed record OrderItemAddedEvent(Guid AggregateId, Guid ProductId, int Quantity)
: BaseDomainEvent(AggregateId);
public sealed record OrderShippedEvent(Guid AggregateId, DateTimeOffset ShippedAtUtc)
: BaseDomainEvent(AggregateId);
The Apply / When pattern in BaseAggregateRoot
Aggregates raise domain events through the protected Apply(IDomainEvent) method inherited from BaseAggregateRoot. The sequence is enforced internally:
// Inside BaseAggregateRoot<TId>
protected void Apply(IDomainEvent domainEvent)
{
this.When(domainEvent); // 1. mutate state
this.EnsureValidState(); // 2. validate post-mutation state
this.domainEvents.Add(domainEvent); // 3. record the event
}
Your aggregate’s public factory and mutation methods call Apply, not When directly:
public sealed class Order : BaseAggregateRoot<OrderId, Guid>
{
private Order(OrderId id, string customerName) : base(id)
{
// Raise the creation event
this.Apply(new OrderCreatedEvent(id.Value, customerName));
}
private Order() { }
public string CustomerName { get; private set; } = string.Empty;
public bool IsShipped { get; private set; }
public static Order Create(OrderId id, string customerName)
{
CheckRule(new CustomerNameMustNotBeEmptyRule(customerName));
return new Order(id, customerName);
}
public void Ship()
{
CheckRule(new OrderMustNotAlreadyBeShippedRule(this.IsShipped));
this.Apply(new OrderShippedEvent(this.Id.Value, DateTimeOffset.UtcNow));
}
protected override void When(IDomainEvent domainEvent)
{
switch (domainEvent)
{
case OrderCreatedEvent e:
this.CustomerName = e.CustomerName;
break;
case OrderShippedEvent:
this.IsShipped = true;
break;
default:
throw new EventCannotBeAppliedToAggregateException<Order, OrderId>(this, domainEvent);
}
}
protected override void EnsureValidState()
{
if (string.IsNullOrWhiteSpace(this.CustomerName))
throw new InvalidAggregateStateException<Order, OrderId>(this, "CustomerName is required.");
}
}
Domain events are collected on the aggregate in memory and are not dispatched until the Unit of Work flushes them. EventsDispatcherService.DispatchEventsFromAggregatesStoreAsync should be called just before committing the transaction (before SaveChanges) so that domain event handlers participate in the same transaction and changes are rolled back atomically on failure.
EventsDispatcherService
EventsDispatcherService iterates all tracked aggregates in the IAggregateStore, collects unpublished events ordered by DateOccurredOnUtc, and publishes them through IEventBus.
public class EventsDispatcherService : IEventsDispatcherService
{
public EventsDispatcherService(
IAggregateStore aggregatesStore,
IEventBus inMemoryEventBus);
public async Task DispatchEventsFromAggregatesStoreAsync(
CancellationToken cancellationToken = default);
}
The corresponding interface:
public interface IEventsDispatcherService
{
/// <summary>
/// Dispatches domain events stored in aggregates store.
/// Call just before committing the transaction when using the Unit of Work pattern.
/// </summary>
Task DispatchEventsFromAggregatesStoreAsync(CancellationToken cancellationToken = default);
}
IEventBus
IEventBus is the publication contract used by EventsDispatcherService. Infrastructure provides the concrete implementation (typically an in-memory MediatR bus or a message broker adapter):
public interface IEventBus
{
Task PublishAsync(IEvent @event, CancellationToken cancellationToken = default);
}
Domain event handlers
Extend the abstract BaseEventHandler<TEvent> class provided by SharedKernel. It implements IInMemoryEventHandler<TEvent>, which in turn extends both IBaseEventHandler<TEvent> (the common handler contract) and MediatR’s INotificationHandler<TEvent>:
// SharedKernel base class — extend this in your application layer
public abstract class BaseEventHandler<TEvent> : IInMemoryEventHandler<TEvent>
where TEvent : IEvent
{
public Task Handle(TEvent notification, CancellationToken cancellationToken)
=> this.HandleAsync(notification, cancellationToken);
public abstract Task HandleAsync(TEvent @event, CancellationToken cancellationToken);
}
The underlying interface contracts:
// Common handler contract (Application.Contracts)
public interface IBaseEventHandler<in TEvent>
where TEvent : IEvent
{
Task HandleAsync(TEvent @event, CancellationToken cancellationToken);
}
// In-memory handler — also wires up MediatR INotificationHandler
public interface IInMemoryEventHandler<in TEvent> : IBaseEventHandler<TEvent>, INotificationHandler<TEvent>
where TEvent : IEvent
{
}
Example handler
public sealed class OrderCreatedEventHandler : BaseEventHandler<OrderCreatedEvent>
{
private readonly IEmailService emailService;
public OrderCreatedEventHandler(IEmailService emailService)
=> this.emailService = emailService;
public override async Task HandleAsync(
OrderCreatedEvent @event,
CancellationToken cancellationToken)
{
// React to the domain event — send a confirmation email
await this.emailService.SendOrderConfirmationAsync(
@event.CustomerName,
@event.AggregateId,
cancellationToken);
}
}
Aggregate raises the event
A public method (e.g. Order.Create) calls Apply(new OrderCreatedEvent(...)). The event is stored in the aggregate’s internal list.
Unit of Work dispatches events
Just before SaveChanges, your infrastructure calls EventsDispatcherService.DispatchEventsFromAggregatesStoreAsync. Unpublished events are published via IEventBus in chronological order.
Handler processes the event
OrderCreatedEventHandler.HandleAsync runs, performing the side effect (email, read-model update, etc.) without any knowledge of the aggregate that raised the event.
Events are cleared
After dispatch, call aggregate.ClearEvents() to prevent re-dispatch on subsequent saves.