Skip to content

Pragmatic.Events

Domain events infrastructure for .NET 10. Raise, dispatch, and handle domain events with zero ceremony. Includes an EF Core interceptor for automatic post-SaveChanges dispatch.

As applications grow, modules need to communicate. A reservation gets confirmed, and billing needs to create an invoice, notifications need to send an email, and audit needs to log the action. The natural instinct is direct method calls:

public async Task ConfirmAsync()
{
Status = ReservationStatus.Confirmed;
await _billing.CreateDraftInvoiceAsync(Id, GuestId, TotalAmount, Currency);
await _notifications.SendConfirmationEmailAsync(GuestId, Id);
await _audit.LogAsync("ReservationConfirmed", Id);
}

Three problems: the entity depends on downstream services, adding a new reaction requires modifying the entity, and if notifications fail the entire confirmation fails — even though the notification is not part of the core business operation.

The entity raises an event describing what happened. Handlers in other boundaries subscribe and react independently. No coupling, no modification of existing code when new consumers appear, and independent failure handling.

// Entity raises an event -- knows nothing about who listens
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 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(/* from @event data */).ConfigureAwait(false);
}
}

Events are dispatched after SaveChangesAsync() commits, so handlers never react to uncommitted changes. Handlers run as internal calls (authorization filters are skipped), and failing handlers do not block other handlers.

See samples/Pragmatic.Events.Samples/ for 7 runnable scenarios: basic dispatch, handler ordering (deterministic via Order property), error resilience (dispatch continues on handler failure), multi-event batch dispatch, registration strategies (manual, assembly scanning, SG-generated), TimeProvider testability, and EF Core interceptor pattern.

| Architecture and Core Concepts | Why Events exists, how the pieces fit, design decisions | | Getting Started | Install and raise your first event in 5 minutes | | Internals | Dispatcher mechanics, handler ordering, observability | | Common Mistakes | Frequent pitfalls with Wrong/Right/Why format | | Troubleshooting | Problem/checklist format for common issues |

PackageDescription
Pragmatic.EventsCore runtime: DomainEventSource, InMemoryEventDispatcher, DI extensions
Pragmatic.Events.EFCoreEF Core SaveChangesInterceptor for automatic dispatch after commit

Both packages target .NET 10. Interfaces (IDomainEvent, IDomainEventDispatcher, IDomainEventHandler<T>, IHasDomainEvents) live in Pragmatic.Abstractions so modules can depend on contracts without pulling in the runtime.

FeatureDescription
IDomainEventMarker interface with OccurredAt timestamp
DomainEventAbstract base record that implements IDomainEvent
IDomainEventHandler<T>Typed handler per event with Order property for execution priority
DomainEventSourceBase class for entities that raise events via RaiseEvent()
IHasDomainEventsInterface alternative when you cannot inherit from DomainEventSource
EntityPropertyChanged<T>Built-in event for cascade property change propagation
[EventHandler]Attribute for SG-based handler discovery and AOT-safe registration
InMemoryEventDispatcherIn-process dispatch with structured logging, OTel tracing, and metrics
DomainEventsInterceptorEF Core interceptor for automatic dispatch after SaveChanges
EventsDiagnosticsActivitySource + Meter with dispatch duration, event count, and failure counters

Events are immutable records. Inherit from DomainEvent for the OccurredAt base, or implement IDomainEvent directly.

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

Events carry all the data downstream consumers need so they never require cross-boundary entity access.

Inherit from DomainEventSource and call RaiseEvent() inside domain operations:

using Pragmatic.Events;
using Pragmatic.Persistence.Entity;
[Entity<Guid>]
[BelongsTo<BookingBoundary>]
public partial class Reservation : DomainEventSource, IEntity<Guid>
{
public ReservationStatus Status { get; private set; }
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;
}
public static Reservation Create(Guid guestId, /* ... */)
{
var reservation = new Reservation { /* ... */ };
reservation.RaiseEvent(new ReservationCreated(/* ... */));
return reservation;
}
}

Events accumulate in the entity’s DomainEvents list. They are not dispatched until persistence completes (see EF Core Integration below).

Implement IDomainEventHandler<T> and mark with [EventHandler] for SG discovery:

using Pragmatic.Events;
using Pragmatic.Events.Attributes;
[EventHandler]
public sealed class ReservationConfirmedHandler(
IBillingActions billingActions) : IDomainEventHandler<ReservationConfirmed>
{
public async Task HandleAsync(ReservationConfirmed @event, CancellationToken ct = default)
{
var taxAmount = Invoice.CalculateTax(@event.TotalAmount);
await billingActions.CreateDraftInvoice(
reservationId: @event.ReservationId,
guestId: @event.GuestId,
subTotal: @event.TotalAmount,
taxAmount: taxAmount,
totalAmount: @event.TotalAmount + taxAmount,
currency: @event.Currency,
issuedAt: @event.OccurredAt,
dueDate: @event.OccurredAt.AddDays(30),
ct: ct).ConfigureAwait(false);
}
}
using Pragmatic.Events.Extensions;
// Register the in-memory dispatcher (scoped lifetime)
builder.Services.AddInMemoryDomainEvents();
// Register handlers individually
builder.Services.AddDomainEventHandler<ReservationConfirmedHandler, ReservationConfirmed>();
// Or scan an assembly (not AOT-safe -- prefer [EventHandler] + SG registration)
builder.Services.AddDomainEventHandlersFromAssembly(typeof(ReservationConfirmedHandler).Assembly);

Add the Pragmatic.Events.EFCore package. The DomainEventsInterceptor dispatches events automatically after SaveChangesAsync() succeeds:

using Pragmatic.Events.EFCore;
services.AddInMemoryDomainEvents();
services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseNpgsql(connectionString);
options.UseDomainEvents(sp); // adds DomainEventsInterceptor
});

No manual dispatch calls needed. After SaveChangesAsync() commits, the interceptor collects, clears, and dispatches all events from tracked entities.

TypePackageDescription
IDomainEventAbstractionsMarker interface with OccurredAt timestamp
DomainEventEventsAbstract base record implementing IDomainEvent
IDomainEventHandler<T>AbstractionsTyped handler with Order (int, default 0) and HandleAsync
IHasDomainEventsAbstractionsEntity contract: DomainEvents list + ClearDomainEvents()
DomainEventSourceEventsBase class implementing IHasDomainEvents with RaiseEvent() / RaiseEvents()
EntityPropertyChanged<T>AbstractionsBuilt-in event for cascade property change propagation
EventHandlerAttributeEventsMarks a handler for SG discovery (AOT-safe registration)
InMemoryEventDispatcherEventsIn-process dispatcher with logging, tracing, metrics
EventsDiagnosticsEventsActivitySource + Meter with dispatch/failure instruments
DomainEventsInterceptorEvents.EFCoreSaveChangesInterceptor for automatic post-commit dispatch
DomainEventsDbContextOptionsBuilderExtensionsEvents.EFCoreUseDomainEvents() extension

If your entity cannot inherit from DomainEventSource (e.g., TPH/TPC hierarchy), implement IHasDomainEvents directly with a private List<IDomainEvent> field.

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

[EventHandler]
public sealed class AuditHandler : IDomainEventHandler<ReservationConfirmed>
{
public int Order => -10; // runs first (audit)
public Task HandleAsync(ReservationConfirmed @event, CancellationToken ct) { /* ... */ }
}
[EventHandler]
public sealed class InvoiceHandler : IDomainEventHandler<ReservationConfirmed>
{
// Order defaults to 0
public Task HandleAsync(ReservationConfirmed @event, CancellationToken ct) { /* ... */ }
}
[EventHandler]
public sealed class NotificationHandler : IDomainEventHandler<ReservationConfirmed>
{
public int Order => 100; // runs last (notification)
public Task HandleAsync(ReservationConfirmed @event, CancellationToken ct) { /* ... */ }
}

When using AddDomainEventHandlersFromAssembly, the order depends on Assembly.GetTypes() ordering, which is not guaranteed stable. If handlers have ordering requirements, register them individually or use the Order property.

Design guidance: handlers should be independent. If you need guaranteed sequencing between two side effects, combine them into a single handler.

Entities use TimeProvider for testable event timestamps:

// Production: uses system clock
var order = new Order();
order.Place("Widget", 5); // OccurredAt = DateTimeOffset.UtcNow
// Testing: inject fake clock for deterministic timestamps
var fakeTime = new DateTimeOffset(2025, 12, 25, 10, 0, 0, TimeSpan.Zero);
var order = new Order(new FakeTimeProvider(fakeTime));
order.Place("Widget", 5); // OccurredAt = 2025-12-25 10:00:00

The InMemoryEventDispatcher continues dispatching to remaining handlers even when one throws. Failures are logged at Error level but do not propagate or block subsequent handlers. This ensures system reactions (audit, notifications) are not lost due to a single handler failure.

InMemoryEventDispatcher uses a continue-on-failure policy. If a handler throws, the exception is:

  1. Logged at Error level with the handler name and event name.
  2. Recorded in the HandlerFailures metric counter.
  3. Recorded as an exception on the current Activity (OTel trace).
  4. Swallowed. The next handler still executes.
Handler A --> succeeds
Handler B --> throws InvalidOperationException (logged, counted, continues)
Handler C --> executes normally

OperationCanceledException is the only exception type that propagates immediately (respects cancellation tokens).

After all handlers complete, if any failures occurred, a Warning-level log records the failure count. There is no built-in retry or circuit breaker. For per-handler resilience, wrap the handler logic in try/catch or use the Resilience module.

Event handlers run as internal calls. The InMemoryEventDispatcher resolves ICallContext (from Pragmatic.Abstractions) and calls EnterInternalCall() before each handler. This means:

  • Authorization filters (permission checks, policy evaluation) are skipped for handler-initiated operations.
  • Data-level filters (tenant isolation, soft delete) remain active — the user context is preserved.
  • If Pragmatic.Actions is not referenced, ICallContext resolves to null and handlers run without call context (no auth filters exist either).

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.

Both InMemoryEventDispatcher and DomainEventsInterceptor use [LoggerMessage] for zero-allocation structured logging at Debug level, with Error for failures.

Distributed tracing: each dispatch creates an Activity named Event.{EventName} on the Pragmatic.Events ActivitySource, tagged with event name, handler count, and failure count.

Metrics on the Pragmatic.Events Meter:

InstrumentTypeDescription
pragmatic.events.dispatchedCounterTotal domain events dispatched
pragmatic.events.dispatch_durationHistogram (ms)Duration of event dispatch (all handlers)
pragmatic.events.handler_failuresCounterTotal event handler failures

See docs/internals.md for the full logging output, tag names, and metric dimensions.

The DomainEventsInterceptor extends SaveChangesInterceptor. It hooks into both SavedChangesAsync and SavedChanges (synchronous fallback).

Step-by-step flow:

  1. SaveChangesAsync() executes and commits to the database.
  2. SavedChangesAsync() fires (post-commit).
  3. Interceptor scans ChangeTracker.Entries<IHasDomainEvents>() for entities with pending events.
  4. Collects all events into a flat list, preserving entity traversal order.
  5. Clears events from entities before dispatch (prevents re-dispatch on retry).
  6. Dispatches the collected list via IDomainEventDispatcher.DispatchAsync(IEnumerable<IDomainEvent>).
  7. If dispatch fails, the exception propagates to the caller.

Because events are cleared before dispatch, a retry of SaveChangesAsync() will not re-dispatch the same events.

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

Example from the Showcase application:

Booking boundary Billing boundary
----------------- ------------------
Reservation.Confirm()
-> RaiseEvent(ReservationConfirmed)
-> DomainEventsInterceptor
-> InMemoryEventDispatcher
-> ReservationCreatedHandler (billing pre-check)
-> ReservationConfirmedHandler (creates draft invoice)
Invoice.MarkAsPaid()
-> RaiseEvent(InvoicePaid)
-> DomainEventsInterceptor
-> InMemoryEventDispatcher
-> InvoicePaidHandler (notifies Booking)

Handlers interact with other boundaries through typed boundary interfaces (e.g., IBillingActions, IBookingActions), never through direct entity access. This keeps boundaries decoupled.

Inject TimeProvider into entities for deterministic timestamps in tests. Pass TimeProvider.GetUtcNow() to RaiseEvent() instead of DateTimeOffset.UtcNow. In test code, provide a FakeTimeProvider to assert exact values.

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, see Pragmatic.Messaging (planned) or implement the transactional outbox pattern:

  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.

Test that an entity raises the correct events:

[Fact]
public void Confirm_RaisesReservationConfirmedEvent()
{
var reservation = Reservation.Create(guestId, propertyId, roomTypeId,
checkIn, checkOut, 2, 300m, "EUR");
reservation.ClearDomainEvents(); // clear creation event
reservation.Confirm();
reservation.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<ReservationConfirmed>()
.Which.TotalAmount.Should().Be(300m);
}

Test the dispatcher with real DI:

[Fact]
public async Task Dispatcher_InvokesRegisteredHandlers()
{
var handler = new TestHandler();
var services = new ServiceCollection();
services.AddSingleton<IDomainEventHandler<OrderPlaced>>(handler);
services.AddLogging();
services.AddInMemoryDomainEvents();
var sp = services.BuildServiceProvider();
var dispatcher = sp.GetRequiredService<IDomainEventDispatcher>();
await dispatcher.DispatchAsync(new OrderPlaced(Guid.NewGuid(), 99.99m, DateTimeOffset.UtcNow));
handler.ReceivedEvents.Should().ContainSingle();
}
PackageRelationship
Pragmatic.AbstractionsDefines the event interfaces
Pragmatic.EnsureParameter guards used by dispatcher and interceptor
Pragmatic.ActionsDomain action pipeline (handlers can invoke actions)
Pragmatic.PersistenceEntity infrastructure (DomainEventSource entities)
Pragmatic.CompositionSG-generated handler registration via [EventHandler]
  • .NET 10.0 or later
  • Pragmatic.Abstractions (transitive)
  • Pragmatic.Ensure (transitive)
  • Microsoft.EntityFrameworkCore 10.0+ (only for Pragmatic.Events.EFCore)

MIT