Skip to content

Architecture and Core Concepts

This guide explains why Pragmatic.Events exists, how its pieces fit together, and how to think about domain events in your application. Read this before diving into the individual feature guides.


As applications grow, modules inevitably need to communicate. One boundary does something, and another boundary needs to react. The natural instinct is direct method calls — and that leads to tight coupling.

public class Reservation
{
private readonly IBillingService _billing;
private readonly INotificationService _notifications;
private readonly IAuditService _audit;
public Reservation(
IBillingService billing,
INotificationService notifications,
IAuditService audit)
{
_billing = billing;
_notifications = notifications;
_audit = audit;
}
public async Task ConfirmAsync()
{
Status = ReservationStatus.Confirmed;
// Booking knows about Billing
await _billing.CreateDraftInvoiceAsync(Id, GuestId, TotalAmount, Currency);
// Booking knows about Notifications
await _notifications.SendConfirmationEmailAsync(GuestId, Id);
// Booking knows about Audit
await _audit.LogAsync("ReservationConfirmed", Id);
}
}

Three problems with this code:

  1. The entity depends on services. Reservation needs IBillingService, INotificationService, and IAuditService injected. An entity should model domain state and behavior, not orchestrate infrastructure.

  2. Adding a reaction requires modifying the source. When a new boundary needs to react to reservation confirmation (analytics, loyalty points, partner API), you modify Reservation.ConfirmAsync(). Every new side effect is another constructor parameter and another await call.

  3. Failure cascading. If _notifications.SendConfirmationEmailAsync() throws, the entire confirmation fails — even though the notification is not part of the core business operation. The caller gets an exception for an unrelated side effect.

Service orchestration: better but still coupled

Section titled “Service orchestration: better but still coupled”
public class ReservationService(
IReservationRepository reservations,
IBillingService billing,
INotificationService notifications,
IAuditService audit)
{
public async Task ConfirmAsync(Guid reservationId, CancellationToken ct)
{
var reservation = await reservations.GetByIdAsync(reservationId, ct);
reservation.Confirm();
await reservations.SaveAsync(reservation, ct);
// Still: every reaction is an explicit call
await billing.CreateDraftInvoiceAsync(reservation.Id, reservation.GuestId,
reservation.TotalAmount, reservation.Currency);
await notifications.SendConfirmationEmailAsync(reservation.GuestId, reservation.Id);
await audit.LogAsync("ReservationConfirmed", reservation.Id);
}
}

Moving the orchestration to a service fixes the entity problem, but the service itself now depends on every downstream boundary. The coupling is still there — it just moved up a level. ReservationService is a god-class that knows about billing, notifications, and audit.

Both approaches violate the Open/Closed Principle: adding a new reaction to “reservation confirmed” requires modifying existing code. The module that performs the action should not need to know (or change) when new consumers appear.


Pragmatic.Events inverts the dependency. The entity raises an event describing what happened. Zero knowledge of who listens. Handlers in other boundaries subscribe to the event and react independently.

// The entity raises an event -- knows nothing about billing, notifications, or audit
[Entity<Guid>]
[BelongsTo<BookingBoundary>]
public partial class Reservation : DomainEventSource, IEntity<Guid>
{
public VoidResult<IError> Confirm()
{
var result = TransitionTo(ReservationStatus.Confirmed);
if (result.IsSuccess)
{
RaiseEvent(new ReservationConfirmed(
Id, GuestId, PropertyId, CheckIn, CheckOut,
TotalAmount, Currency, DateTimeOffset.UtcNow));
}
return result;
}
}
// Billing boundary reacts -- knows nothing about Booking internals
[EventHandler]
public sealed class ReservationConfirmedHandler(
IBillingActions billingActions) : IDomainEventHandler<ReservationConfirmed>
{
public async Task HandleAsync(ReservationConfirmed @event, CancellationToken ct = default)
{
await billingActions.CreateDraftInvoice(
reservationId: @event.ReservationId,
guestId: @event.GuestId,
totalAmount: @event.TotalAmount,
currency: @event.Currency,
ct: ct).ConfigureAwait(false);
}
}

Adding a new reaction — analytics, loyalty, partner API — means adding a new handler class. The Reservation entity and the ReservationConfirmedHandler never change.

Three guarantees:

  1. Decoupled boundaries. The entity raises events; handlers react. Neither knows about the other. They share only the event record.
  2. Continue-on-failure. If the notification handler throws, the billing handler still runs. Independent side effects do not block each other.
  3. Post-persistence dispatch. Events are dispatched after SaveChangesAsync() succeeds. If the database write fails, no handlers fire. You never react to something that did not actually happen.

A domain event flows through four stages, from creation inside an entity to handler execution after persistence.

Entity Operation
|
v
RaiseEvent(event) Event added to entity's DomainEvents list
| (no dispatch yet)
v
SaveChangesAsync() EF Core persists the entity changes
|
v
DomainEventsInterceptor Post-commit: collects events from all tracked entities
| Clears events from entities (prevents re-dispatch on retry)
v
InMemoryEventDispatcher
|
+---> Handler A (Order -10) Sorted by Order, ascending
| ICallContext.EnterInternalCall()
| handler.HandleAsync(@event)
|
+---> Handler B (Order 0) Continue-on-failure: if A throws, B still runs
| ICallContext.EnterInternalCall()
| handler.HandleAsync(@event)
|
+---> Handler C (Order 100)
ICallContext.EnterInternalCall()
handler.HandleAsync(@event)

Key points:

  • Events accumulate, not dispatch immediately. RaiseEvent() adds the event to the entity’s internal list. Nothing happens until persistence completes.
  • The interceptor collects and clears before dispatching. This ordering prevents double-dispatch if SaveChangesAsync() is retried by a resilience wrapper.
  • Each handler runs as an internal call. Authorization filters are skipped because handlers are system reactions, not user-initiated operations.
  • OperationCanceledException propagates immediately. All other exceptions are caught, logged, and counted — but do not stop the dispatch chain.

The marker interface for all domain events. Defined in Pragmatic.Abstractions so modules can depend on the contract without pulling in the runtime.

Pragmatic.Abstractions
public interface IDomainEvent
{
DateTimeOffset OccurredAt { get; }
}

Events are immutable records that carry all the data downstream consumers need. The event is the contract between boundaries — handlers should never need to query back into the originating boundary.

Pragmatic.Events provides an abstract base record for convenience:

Pragmatic.Events
public abstract record DomainEvent(DateTimeOffset OccurredAt) : IDomainEvent;

Inherit from DomainEvent for automatic OccurredAt population:

public sealed record ReservationConfirmed(
Guid ReservationId,
Guid GuestId,
Guid PropertyId,
DateTimeOffset CheckIn,
DateTimeOffset CheckOut,
decimal TotalAmount,
string Currency,
DateTimeOffset OccurredAt) : DomainEvent(OccurredAt);

Or implement IDomainEvent directly when you want full control over the record shape:

public sealed record ReservationConfirmed(
Guid ReservationId,
Guid GuestId,
decimal TotalAmount,
string Currency,
DateTimeOffset OccurredAt) : IDomainEvent;
GuidelineRationale
Use sealed recordImmutability by default; value equality for testing
Include all data handlers needHandlers should not query the originating boundary
Use DateTimeOffset, not DateTimeTimezone-safe timestamps
Name events in past tenseReservationConfirmed, not ConfirmReservation
Include entity IDs, not entity referencesEvents are data snapshots, not live object graphs
Keep events focusedOne event per business fact; avoid “mega events” with every field

The typed handler interface. Each handler processes one event type. Defined in Pragmatic.Abstractions.

Pragmatic.Abstractions
public interface IDomainEventHandler<in TEvent>
where TEvent : IDomainEvent
{
int Order => 0;
Task HandleAsync(TEvent @event, CancellationToken ct = default);
}

Handlers execute in ascending Order (lower values run first). The default is 0. Within the same Order value, execution follows DI registration order.

// Runs first -- audit is the highest priority
[EventHandler]
public sealed class AuditHandler : IDomainEventHandler<ReservationConfirmed>
{
public int Order => -10;
public Task HandleAsync(ReservationConfirmed @event, CancellationToken ct) { /* ... */ }
}
// Runs second -- default Order is 0
[EventHandler]
public sealed class InvoiceHandler : IDomainEventHandler<ReservationConfirmed>
{
public Task HandleAsync(ReservationConfirmed @event, CancellationToken ct) { /* ... */ }
}
// Runs last -- notifications are least critical
[EventHandler]
public sealed class NotificationHandler : IDomainEventHandler<ReservationConfirmed>
{
public int Order => 100;
public Task HandleAsync(ReservationConfirmed @event, CancellationToken ct) { /* ... */ }
}

Design guidance: handlers should be independent. If you need guaranteed sequencing between two side effects (e.g., “create invoice then send invoice email”), combine them into a single handler. Do not rely on Order for transactional coordination.

Mark handler classes with [EventHandler] for source generator discovery. The Composition SG generates AOT-safe DI registration code (AddPragmaticEventHandlers()) for all classes with this attribute.

using Pragmatic.Events.Attributes;
[EventHandler]
public sealed class ReservationConfirmedHandler(
IBillingActions billingActions) : IDomainEventHandler<ReservationConfirmed>
{
public async Task HandleAsync(ReservationConfirmed @event, CancellationToken ct = default)
{
// React to the event
}
}

The attribute is optional for runtime correctness (the handler works with manual DI registration), but it is the recommended approach for AOT safety and zero-ceremony registration.


Entities raise events through two mechanisms. Both work with the EF Core interceptor.

Inherit from DomainEventSource when your entity has no other base class requirement:

[Entity<Guid>]
public partial class Reservation : DomainEventSource, IEntity<Guid>
{
public VoidResult<IError> Confirm()
{
var result = TransitionTo(ReservationStatus.Confirmed);
if (result.IsSuccess)
{
RaiseEvent(new ReservationConfirmed(/* ... */));
}
return result;
}
public static Reservation Create(/* params */)
{
var reservation = new Reservation { /* ... */ };
reservation.RaiseEvent(new ReservationCreated(/* ... */));
return reservation;
}
}

DomainEventSource provides:

MemberDescription
DomainEventsIReadOnlyList<IDomainEvent> — accumulated events, read-only
ClearDomainEvents()Removes all pending events (called by the interceptor)
RaiseEvent(IDomainEvent)Adds a single event to the list
RaiseEvents(IEnumerable<IDomainEvent>)Adds multiple events

Events accumulate in the entity and are not dispatched until the EF Core interceptor fires after SaveChangesAsync().

When your entity already has a base class (EF Core TPH/TPC hierarchy, a shared base for audit fields, etc.), implement IHasDomainEvents directly:

public class Reservation : MyExistingBaseClass, IHasDomainEvents
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
public void ClearDomainEvents() => _domainEvents.Clear();
public VoidResult<IError> Confirm()
{
// ...
_domainEvents.Add(new ReservationConfirmed(/* ... */));
// ...
}
}

The EF Core interceptor scans for IHasDomainEvents (not DomainEventSource), so both approaches work identically for dispatch.

SituationUse
Entity has no base classDomainEventSource — less boilerplate
Entity already has a base classIHasDomainEvents — implement the interface manually
Entity in TPH/TPC hierarchyIHasDomainEvents — cannot change the hierarchy root
Lightweight value object that raises eventsIHasDomainEvents — value objects should not inherit from a class

The built-in dispatcher resolves handlers from DI and invokes them in-process. Registered as scoped by AddInMemoryDomainEvents().

Two dispatch paths:

MethodUse Case
DispatchAsync<TEvent>(event)Typed — resolves IDomainEventHandler<TEvent> directly
DispatchAsync(IEnumerable<IDomainEvent>)Batch (untyped) — used by the EF Core interceptor when event types are mixed

The batch path bridges each event to the typed path using cached compiled delegates (ConcurrentDictionary<Type, Func<...>>). MakeGenericMethod is called once per event type, then the delegate is reused. For AOT scenarios, use the typed DispatchAsync<TEvent>() directly.

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 OTel traces.

In a multi-handler scenario, independent side effects (create invoice, send email, update analytics) should not block each other. If the notification handler fails, the billing handler should still run. Each side effect is independent.

If you need transactional guarantees across multiple handlers:

  • Combine them into a single handler. One handler, one transaction.
  • Use the outbox pattern. Write events to an outbox table in the same transaction, then process them via a background worker with retries.

OperationCanceledException propagates immediately. Within a single event’s handlers, it stops the handler chain. In the batch dispatch loop, it stops before the next event. This respects cancellation tokens (request aborted, shutdown).


The Pragmatic.Events.EFCore package provides automatic event dispatch after SaveChangesAsync(). No manual dispatch calls needed.

Extends SaveChangesInterceptor and hooks into both async and sync paths:

OverrideWhen It Fires
SavedChangesAsyncAfter SaveChangesAsync() commits
SavedChangesAfter synchronous SaveChanges() commits (sync fallback)
SaveChangesAsync() commits to database
|
v
SavedChangesAsync fires (post-commit)
|
v
Scan ChangeTracker.Entries<IHasDomainEvents>()
|
v
Filter entities where DomainEvents.Count > 0
|
v
Collect all events into a flat List<IDomainEvent>
|
v
Log: "{count} domain event(s) collected from {entityCount} entity/entities"
|
v
Clear events from all entities (entity.ClearDomainEvents())
|
v
Log: "Domain events cleared from entities before dispatch"
|
v
Dispatch via IDomainEventDispatcher.DispatchAsync(allEvents, ct)
|
v
If dispatch fails: log Error + re-throw

Events are cleared from entities before dispatch, not after. This prevents re-dispatch if SaveChangesAsync() is retried (e.g., by a resilience wrapper or EF Core execution strategy). If events were cleared after dispatch, a retry would collect and dispatch the same events again.

using Pragmatic.Events.EFCore;
using Pragmatic.Events.Extensions;
// 1. Register the dispatcher
services.AddInMemoryDomainEvents();
// 2. Add the interceptor to your DbContext
services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseNpgsql(connectionString);
options.UseDomainEvents(sp); // adds DomainEventsInterceptor
});

UseDomainEvents(sp) resolves IDomainEventDispatcher from the service provider and creates the interceptor. The interceptor accepts an optional ILogger<DomainEventsInterceptor>; when null, it falls back to NullLogger.


Event handlers run as internal calls. The InMemoryEventDispatcher resolves ICallContext (from Pragmatic.Abstractions, namespace Pragmatic.Pipeline) and calls EnterInternalCall() before each handler.

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();
}
}
Filter LevelBehavior During Event Handlers
Authorization (L1-L3: permissions, policies, ABAC)SkippedIsInternalCall is true
Data-level filters (L4: tenant isolation, soft delete)Active — user context is preserved

This design ensures that system reactions to domain events (e.g., creating an invoice after reservation confirmation) are not blocked by the HTTP user’s permissions. The handler runs in the same DI scope as the original request, so tenant isolation and soft delete filters still apply correctly.

If Pragmatic.Actions is not referenced in your project, ICallContext is not registered in DI. GetService<ICallContext>() returns null, scope is null, and ?.Dispose() is a no-op. Handlers execute normally without any call context — and since no authorization filters exist either, there is nothing to skip.


Domain events are the primary mechanism for cross-boundary communication in Pragmatic applications. An event raised in one boundary triggers handlers registered in another boundary.

Booking boundary Billing boundary
----------------- ------------------
Reservation.Confirm()
-> RaiseEvent(ReservationConfirmed)
-> SaveChangesAsync()
-> DomainEventsInterceptor
-> InMemoryEventDispatcher
-> ReservationConfirmedHandler (creates draft invoice)
-> AuditHandler (audit trail)
Invoice.MarkAsPaid()
-> RaiseEvent(InvoicePaid)
-> SaveChangesAsync()
-> DomainEventsInterceptor
-> InMemoryEventDispatcher
-> InvoicePaidHandler (notifies Booking)

Boundaries share only the event record. Handlers interact with other boundaries through typed boundary interfaces (e.g., IBillingActions, IBookingActions), never through direct entity or repository access. This keeps boundaries decoupled.

// Billing handler reacts to Booking event
// Knows about: ReservationConfirmed (the event) and IBillingActions (its own boundary)
// Does NOT know about: Reservation entity, IReservationRepository, BookingDbContext
[EventHandler]
public sealed class ReservationConfirmedHandler(
IBillingActions billingActions) : IDomainEventHandler<ReservationConfirmed>
{
public async Task HandleAsync(ReservationConfirmed @event, CancellationToken ct = default)
{
await billingActions.CreateDraftInvoice(
reservationId: @event.ReservationId,
guestId: @event.GuestId,
totalAmount: @event.TotalAmount,
currency: @event.Currency,
ct: ct).ConfigureAwait(false);
}
}

Include all the data handlers need in the event. If ReservationConfirmedHandler needs the reservation amount and currency, those fields must be in ReservationConfirmed. If you find yourself injecting a repository from another boundary to look up data, the event is missing fields.

// Good: event carries all needed data
public sealed record ReservationConfirmed(
Guid ReservationId,
Guid GuestId,
Guid PropertyId,
DateTimeOffset CheckIn,
DateTimeOffset CheckOut,
decimal TotalAmount,
string Currency,
DateTimeOffset OccurredAt) : DomainEvent(OccurredAt);
// Bad: event is too slim, handler needs to query Booking
public sealed record ReservationConfirmed(
Guid ReservationId,
DateTimeOffset OccurredAt) : DomainEvent(OccurredAt);
// Handler would need IReservationRepository to look up amount, currency, etc.

A built-in generic event for cascade property change propagation. Defined in Pragmatic.Abstractions.

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;
public static EntityPropertyChanged<TEntity> Create(
object entityId, string propertyName, object? oldValue, object? newValue)
=> new()
{
EntityId = entityId,
PropertyName = propertyName,
OldValue = oldValue,
NewValue = newValue
};
}

When a parent entity’s property changes and dependent entities need to react. For example, when RoomType.BaseRate changes, reservations priced against that room type may need updating:

// Entity raises the property change event
public partial class RoomType : DomainEventSource
{
public void UpdateBaseRate(decimal newRate)
{
var oldRate = BaseRate;
BaseRate = newRate;
RaiseEvent(EntityPropertyChanged<RoomType>.Create(Id, nameof(BaseRate), oldRate, newRate));
}
}
// Handler reacts to the change
[EventHandler]
public sealed class RoomTypeRateChangedHandler(
IReservationRepository reservations)
: IDomainEventHandler<EntityPropertyChanged<RoomType>>
{
public async Task HandleAsync(
EntityPropertyChanged<RoomType> @event, CancellationToken ct = default)
{
if (@event.PropertyName == nameof(RoomType.BaseRate))
{
// Update future reservations with the new rate
}
}
}

EntityPropertyChanged<T> is also used by SG-generated setters in Pragmatic.Persistence for automatic change propagation.


Handlers for the same event type are sorted by Order (ascending, lower first):

var handlers = _serviceProvider.GetServices<IDomainEventHandler<TEvent>>()
.OrderBy(h => h.Order)
.ToList();
Order ValueConventionExample
Negative (e.g., -10)High-priority side effects (audit, logging)AuditHandler
0 (default)Normal business reactionsInvoiceHandler
Positive (e.g., 100)Low-priority, non-criticalNotificationHandler

Within the same Order value, execution follows DI registration order. When using AddDomainEventHandlersFromAssembly, the registration order depends on Assembly.GetTypes() ordering, which is not guaranteed stable across builds. Prefer individual registration or the SG-generated registration (which uses a deterministic order) if handler sequencing matters.


TypeLifetimeRegistration Method
IDomainEventDispatcher / InMemoryEventDispatcherScopedAddInMemoryDomainEvents() via TryAddScoped
IDomainEventHandler<T>ScopedAddDomainEventHandler<THandler, TEvent>()
DomainEventsInterceptorSingleton (implicit)Created once per DbContextOptions by UseDomainEvents()

The dispatcher is scoped because it resolves handlers from the current DI scope. The interceptor is effectively singleton because it is attached to DbContextOptions, but it delegates to the scoped dispatcher resolved at dispatch time through the service provider.


Both InMemoryEventDispatcher and DomainEventsInterceptor use [LoggerMessage] for zero-allocation structured logging.

Each dispatch creates an Activity named Event.{EventName} on the Pragmatic.Events ActivitySource, tagged with:

TagValue
pragmatic.event.nameEvent type name (e.g., ReservationConfirmed)
pragmatic.event.handler_countNumber of handlers resolved
event.handler_failuresNumber of failed handlers (only when > 0)

Three instruments on the Pragmatic.Events Meter:

InstrumentTypeDescription
pragmatic.events.dispatchedCounterTotal domain events dispatched (tagged by event.name)
pragmatic.events.dispatch_durationHistogram (ms)Duration of event dispatch across all handlers (tagged by event.name)
pragmatic.events.handler_failuresCounterTotal handler failures (tagged by event.name and handler.name)
LevelWhat Is Logged
DebugDispatching event, handler count, handler executing/completed, events collected/cleared
WarningDispatch completed with failures (includes failure count)
ErrorHandler failure (with exception), dispatch failure in interceptor

Pragmatic.Events itself does not have a dedicated source generator. The event infrastructure integrates with two existing generators:

When Pragmatic.Composition is referenced, the Composition source generator discovers all classes marked with [EventHandler] and generates AOT-safe DI registration:

_Infra.Events.Registration.g.cs
public static class EventsRegistrationExtensions
{
public static IServiceCollection AddPragmaticEventHandlers(this IServiceCollection services)
{
services.AddScoped<IDomainEventHandler<ReservationConfirmed>, ReservationConfirmedHandler>();
services.AddScoped<IDomainEventHandler<InvoicePaid>, InvoicePaidHandler>();
// ... one registration per [EventHandler] class
return services;
}
}

When Pragmatic.Persistence generates setters for entity properties, it can emit EntityPropertyChanged<T> events automatically for properties that participate in cascade propagation.

Without Pragmatic.Composition, register handlers manually:

// Individual registration (AOT-safe)
services.AddDomainEventHandler<ReservationConfirmedHandler, ReservationConfirmed>();
// Assembly scanning (not AOT-safe)
services.AddDomainEventHandlersFromAssembly(typeof(ReservationConfirmedHandler).Assembly);

Three methods are available for registering event infrastructure:

using Pragmatic.Events.Extensions;
// 1. Register the dispatcher (required)
services.AddInMemoryDomainEvents();
// 2a. Register handlers individually (AOT-safe)
services.AddDomainEventHandler<ReservationConfirmedHandler, ReservationConfirmed>();
services.AddDomainEventHandler<InvoicePaidHandler, InvoicePaid>();
// 2b. Scan an assembly (not AOT-safe)
services.AddDomainEventHandlersFromAssembly(typeof(ReservationConfirmedHandler).Assembly);
// 2c. Use SG-generated registration (AOT-safe, recommended)
// Requires [EventHandler] attribute on handler classes + Pragmatic.Composition
MethodAOT-SafeDiscoveryWhen to Use
AddDomainEventHandler<THandler, TEvent>()YesManualSmall projects, explicit control
AddDomainEventHandlersFromAssembly()NoReflectionQuick prototyping
[EventHandler] + SG registrationYesCompile-timeProduction (recommended)

Pragmatic.Events integrates with other Pragmatic modules. Each integration is opt-in.

Event handlers commonly invoke domain actions through typed boundary interfaces. The internal call context ensures that handler-triggered actions bypass authorization filters.

DomainEventSource entities accumulate events during domain operations. The EF Core interceptor dispatches them after SaveChangesAsync(). SG-generated setters can emit EntityPropertyChanged<T> for cascade propagation.

The Composition SG discovers [EventHandler] classes and generates AOT-safe registration. When using PragmaticApp.RunAsync(), handler registration is automatic.

Event handlers run as internal calls (ICallContext.EnterInternalCall()). This means:

  • Permission checks (L1), policy evaluation (L2), and ABAC (L3) are skipped.
  • Data-level filters (L4: tenant isolation, soft delete) remain active.

Pragmatic.Messaging will extend the event infrastructure with an outbox pattern, message broker integration, and at-least-once delivery guarantees. The same IDomainEvent records and IDomainEventHandler<T> interface will be reused.


InMemoryEventDispatcher is synchronous and in-process. It does not include a built-in outbox because:

  • An outbox is tightly coupled to your persistence technology and transaction strategy.
  • Outbox consumers require a background worker, polling interval, and dead-letter handling.
  • These concerns belong in a dedicated messaging module.

If you need at-least-once delivery or cross-service messaging before Pragmatic.Messaging is available:

  1. Within the same transaction as your domain write, insert events into an OutboxMessages table.
  2. A background worker polls the table and publishes events to a broker.
  3. Consumers process and acknowledge.

The in-memory dispatcher remains useful as the immediate, same-process dispatch mechanism alongside an outbox for reliable cross-process delivery.


TypeThread SafetyNotes
InMemoryEventDispatcherSafeStateless per-call; the static delegate cache is a ConcurrentDictionary
DomainEventsInterceptorSafeStateless; holds only the dispatcher reference
DomainEventSourceNot thread-safeEntity instances are scoped to a single request/DbContext

DomainEventSource is not thread-safe because entity instances are expected to be used within a single DI scope (one DbContext, one request). Do not share entities across threads.


Use TimeProvider for deterministic timestamps in tests. Pass TimeProvider.GetUtcNow() to event constructors instead of DateTimeOffset.UtcNow:

// Production: use real time
RaiseEvent(new ReservationConfirmed(Id, GuestId, PropertyId,
CheckIn, CheckOut, TotalAmount, Currency, DateTimeOffset.UtcNow));
// Test: use FakeTimeProvider for deterministic assertions
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 3, 15, 10, 0, 0, TimeSpan.Zero));
RaiseEvent(new ReservationConfirmed(Id, GuestId, PropertyId,
CheckIn, CheckOut, TotalAmount, Currency, timeProvider.GetUtcNow()));