Pragmatic.Events Internals
Deep dive into dispatcher mechanics, handler ordering, error handling, the EF Core interceptor, and observability.
InMemoryEventDispatcher
Section titled “InMemoryEventDispatcher”Architecture
Section titled “Architecture”InMemoryEventDispatcher implements IDomainEventDispatcher and is registered as scoped by AddInMemoryDomainEvents(). It resolves handlers from IServiceProvider at dispatch time.
Two dispatch paths exist:
| Method | Use case |
|---|---|
DispatchAsync<TEvent>(event) | Typed dispatch — resolves IDomainEventHandler<TEvent> directly |
DispatchAsync(IEnumerable<IDomainEvent>) | Untyped batch — used by the EF Core interceptor when event types are mixed |
Typed Dispatch Flow
Section titled “Typed Dispatch Flow”DispatchAsync<TEvent>(@event) 1. Start Activity "Event.{EventName}" on Pragmatic.Events ActivitySource 2. Increment pragmatic.events.dispatched counter 3. Resolve all IDomainEventHandler<TEvent> from IServiceProvider 4. Sort handlers by Order (ascending) 5. For each handler: a. Resolve ICallContext (optional, from Pragmatic.Abstractions) b. Enter internal call scope (skips auth filters) c. handler.HandleAsync(@event, ct) d. Dispose internal call scope e. On exception (except OperationCanceledException): - Log at Error level - Increment pragmatic.events.handler_failures counter - Record exception on Activity - Continue to next handler 6. Record dispatch duration in pragmatic.events.dispatch_duration histogram 7. If failures > 0, log Warning with failure countUntyped Batch Dispatch
Section titled “Untyped Batch Dispatch”When DispatchAsync(IEnumerable<IDomainEvent>) is called (typically by the EF Core interceptor), the dispatcher iterates events and bridges each to the typed path.
The bridge uses cached compiled delegates to avoid reflection on every call. For each event type, MakeGenericMethod is called once to create a delegate (dispatcher, event, ct) => Task. The delegate is stored in a ConcurrentDictionary<Type, Func<...>> and reused for all subsequent events of the same type.
DispatchAsync(events) for each event: 1. Check CancellationToken 2. Look up cached delegate for event.GetType() 3. If cache miss: create delegate via MakeGenericMethod (once per type) 4. Invoke delegate -> calls typed DispatchAsync<TEvent>The MakeGenericMethod call carries an [RequiresDynamicCode] annotation. For AOT scenarios, use the typed DispatchAsync<TEvent> directly.
Handler Ordering
Section titled “Handler Ordering”Handlers are sorted by the Order property (default 0, lower runs first):
var handlers = _serviceProvider.GetServices<IDomainEventHandler<TEvent>>() .OrderBy(h => h.Order) .ToList();Within the same Order value, execution follows DI registration order. If you use AddDomainEventHandlersFromAssembly, the registration order depends on Assembly.GetTypes() which is not guaranteed stable across builds. Prefer individual registration or the Order property for deterministic sequencing.
Error Handling
Section titled “Error Handling”Continue-on-Failure Strategy
Section titled “Continue-on-Failure Strategy”The dispatcher catches all exceptions except OperationCanceledException:
catch (Exception ex) when (ex is not OperationCanceledException){ handlerFailures++; EventsDiagnostics.HandlerFailures.Add(1, ...); LogHandlerFailed(eventName, handlerName, ex); activity?.RecordException(ex);}This means:
- A failing handler does not block subsequent handlers.
- All handlers are invoked regardless of individual failures.
- The dispatcher itself does not throw (except on cancellation).
- Failures are observable via logs, metrics, and traces.
Why Not Fail-Fast?
Section titled “Why Not Fail-Fast?”In a multi-handler scenario, independent side effects (send email, update read model, emit metric) should not block each other. If Handler B fails, Handler C (which is unrelated) should still execute. This is the standard behavior for in-process event dispatch.
If you need transactional guarantees across multiple handlers, either:
- Combine the logic into a single handler.
- Use the outbox pattern with a message broker.
OperationCanceledException
Section titled “OperationCanceledException”OperationCanceledException propagates immediately from the batch dispatch loop (before reaching the next event). Within a single event’s handlers, it propagates through the catch filter and stops the current event’s handler chain.
Internal Call Context
Section titled “Internal Call Context”The dispatcher resolves ICallContext (defined in Pragmatic.Abstractions, namespace Pragmatic.Pipeline) as an optional service. If present (i.e., Pragmatic.Actions is referenced), each handler runs inside an internal call scope:
var callContext = _serviceProvider.GetService<Pragmatic.Pipeline.ICallContext>();
foreach (var handler in handlers){ var scope = callContext?.EnterInternalCall(); try { await handler.HandleAsync(@event, ct).ConfigureAwait(false); } finally { scope?.Dispose(); }}Effect: when IsInternalCall is true, authorization filters (L1 permission, L2 policy, L3 ABAC) skip their checks. This allows event handlers to invoke domain actions (via boundary interfaces) without being blocked by the HTTP caller’s permissions.
Data-level filters (L4 — tenant isolation, soft delete) remain active. The user context is preserved because the handler runs in the same DI scope as the original request.
If ICallContext is not registered (e.g., Pragmatic.Actions is not referenced), GetService returns null and scope is null. The ?.Dispose() is a no-op. Handlers execute normally without any call context.
EF Core Interceptor
Section titled “EF Core Interceptor”DomainEventsInterceptor
Section titled “DomainEventsInterceptor”Extends SaveChangesInterceptor and hooks into both async and sync paths:
| Override | When it fires |
|---|---|
SavedChangesAsync | After SaveChangesAsync() commits |
SavedChanges | After synchronous SaveChanges() commits |
The sync path calls the async dispatch method via .GetAwaiter().GetResult().
Dispatch Flow
Section titled “Dispatch Flow”SavedChangesAsync (post-commit) 1. If context is null, return immediately 2. Scan ChangeTracker.Entries<IHasDomainEvents>() 3. Filter entities where DomainEvents.Count > 0 4. Collect all events into a flat List<IDomainEvent> 5. Log: "{count} domain event(s) collected from {entityCount} entity/entities" 6. Clear events from all entities (entity.ClearDomainEvents()) 7. Log: "Domain events cleared from entities before dispatch" 8. Call _dispatcher.DispatchAsync(allEvents, ct) 9. On exception: log Error + re-throwWhy Clear Before Dispatch?
Section titled “Why Clear Before Dispatch?”Events are cleared from entities before dispatch, not after. This prevents re-dispatch if SaveChangesAsync is retried (e.g., by a resilience wrapper). If events were cleared after dispatch, a retry would collect and dispatch the same events again.
Registration
Section titled “Registration”The UseDomainEvents(sp) extension resolves IDomainEventDispatcher from the service provider and creates the interceptor:
public static DbContextOptionsBuilder UseDomainEvents( this DbContextOptionsBuilder optionsBuilder, IServiceProvider serviceProvider){ var dispatcher = serviceProvider.GetRequiredService<IDomainEventDispatcher>(); optionsBuilder.AddInterceptors(new DomainEventsInterceptor(dispatcher)); return optionsBuilder;}The interceptor accepts an optional ILogger<DomainEventsInterceptor>. When null (e.g., when constructed directly), it falls back to NullLogger.
Interceptor Logging
Section titled “Interceptor Logging”Uses [LoggerMessage] partial methods for zero-allocation structured logging:
| Level | Message |
|---|---|
| Debug | SaveChanges: {eventCount} domain event(s) collected from {entityCount} entity/entities |
| Debug | Domain events cleared from entities before dispatch |
| Debug | Dispatching {eventCount} domain event(s) |
| Error | Domain event dispatch failed (with exception) |
Observability
Section titled “Observability”EventsDiagnostics
Section titled “EventsDiagnostics”All diagnostics are centralized in EventsDiagnostics (namespace Pragmatic.Events.Diagnostics):
public static class EventsDiagnostics{ public const string SourceName = "Pragmatic.Events"; public static readonly ActivitySource ActivitySource; public static readonly Meter Meter;
// Instruments: public static readonly Histogram<double> DispatchDuration; // ms public static readonly Counter<long> EventsDispatched; // total events public static readonly Counter<long> HandlerFailures; // total failures}Activity Tags
Section titled “Activity Tags”Tags follow the naming conventions in EventTags (from Pragmatic.Abstractions):
| Tag constant | Tag name | Description |
|---|---|---|
EventTags.Name | pragmatic.event.name | Event type name |
EventTags.Handler | pragmatic.event.handler | Handler type name (available for per-handler spans) |
EventTags.HandlerCount | pragmatic.event.handler_count | Number of handlers resolved |
The dispatcher also sets event.handler_failures (not in EventTags) when failures occur.
Metric Dimensions
Section titled “Metric Dimensions”Both EventsDispatched and HandlerFailures are tagged with event.name. HandlerFailures also includes handler.name. This allows dashboards to slice by event type and failing handler.
EntityPropertyChanged<TEntity>
Section titled “EntityPropertyChanged<TEntity>”A built-in generic event for cascade property propagation:
public sealed record EntityPropertyChanged<TEntity> : IDomainEvent where TEntity : class{ public required object EntityId { get; init; } public required string PropertyName { get; init; } public object? NewValue { get; init; } public object? OldValue { get; init; } public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;}Used when a parent entity’s property changes and dependent entities need to react. For example, when RoomType.BaseRate changes, a handler can update all associated reservations:
[EventHandler]public sealed class RoomTypeRateChangedHandler : IDomainEventHandler<EntityPropertyChanged<RoomType>>{ public async Task HandleAsync( EntityPropertyChanged<RoomType> @event, CancellationToken ct = default) { if (@event.PropertyName == nameof(RoomType.BaseRate)) { // Update dependent reservations with new rate } }}DI Lifetimes
Section titled “DI Lifetimes”| Type | Lifetime | Registration |
|---|---|---|
IDomainEventDispatcher / InMemoryEventDispatcher | Scoped | AddInMemoryDomainEvents() via TryAddScoped |
IDomainEventHandler<T> | Scoped | AddDomainEventHandler<THandler, TEvent>() |
DomainEventsInterceptor | Singleton (implicit) | Created once per DbContextOptions by UseDomainEvents() |
The dispatcher is scoped because it resolves handlers from the current scope. The interceptor is effectively singleton because it is attached to DbContextOptions, but it delegates to the scoped dispatcher resolved at dispatch time.
Thread Safety
Section titled “Thread Safety”InMemoryEventDispatcher: stateless per-call (all state lives in local variables). The staticDispatchDelegatesdictionary is aConcurrentDictionaryand is safe for concurrent access.DomainEventsInterceptor: stateless — holds only the dispatcher reference.DomainEventSource: not thread-safe. Entity instances are expected to be used within a single scope (one DbContext, one request).