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.
The Problem
Section titled “The Problem”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 Solution
Section titled “The Solution”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 listenspublic 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.
Samples
Section titled “Samples”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 |
Packages
Section titled “Packages”| Package | Description |
|---|---|
Pragmatic.Events | Core runtime: DomainEventSource, InMemoryEventDispatcher, DI extensions |
Pragmatic.Events.EFCore | EF 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.
Features
Section titled “Features”| Feature | Description |
|---|---|
IDomainEvent | Marker interface with OccurredAt timestamp |
DomainEvent | Abstract base record that implements IDomainEvent |
IDomainEventHandler<T> | Typed handler per event with Order property for execution priority |
DomainEventSource | Base class for entities that raise events via RaiseEvent() |
IHasDomainEvents | Interface 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 |
InMemoryEventDispatcher | In-process dispatch with structured logging, OTel tracing, and metrics |
DomainEventsInterceptor | EF Core interceptor for automatic dispatch after SaveChanges |
EventsDiagnostics | ActivitySource + Meter with dispatch duration, event count, and failure counters |
Quick Start
Section titled “Quick Start”1. Define a Domain Event
Section titled “1. Define a Domain Event”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.
2. Raise Events from an Entity
Section titled “2. Raise Events from an Entity”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).
3. Handle Events
Section titled “3. Handle Events”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); }}4. Register Services
Section titled “4. Register Services”using Pragmatic.Events.Extensions;
// Register the in-memory dispatcher (scoped lifetime)builder.Services.AddInMemoryDomainEvents();
// Register handlers individuallybuilder.Services.AddDomainEventHandler<ReservationConfirmedHandler, ReservationConfirmed>();
// Or scan an assembly (not AOT-safe -- prefer [EventHandler] + SG registration)builder.Services.AddDomainEventHandlersFromAssembly(typeof(ReservationConfirmedHandler).Assembly);5. EF Core Integration
Section titled “5. EF Core Integration”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.
Core Types
Section titled “Core Types”| Type | Package | Description |
|---|---|---|
IDomainEvent | Abstractions | Marker interface with OccurredAt timestamp |
DomainEvent | Events | Abstract base record implementing IDomainEvent |
IDomainEventHandler<T> | Abstractions | Typed handler with Order (int, default 0) and HandleAsync |
IHasDomainEvents | Abstractions | Entity contract: DomainEvents list + ClearDomainEvents() |
DomainEventSource | Events | Base class implementing IHasDomainEvents with RaiseEvent() / RaiseEvents() |
EntityPropertyChanged<T> | Abstractions | Built-in event for cascade property change propagation |
EventHandlerAttribute | Events | Marks a handler for SG discovery (AOT-safe registration) |
InMemoryEventDispatcher | Events | In-process dispatcher with logging, tracing, metrics |
EventsDiagnostics | Events | ActivitySource + Meter with dispatch/failure instruments |
DomainEventsInterceptor | Events.EFCore | SaveChangesInterceptor for automatic post-commit dispatch |
DomainEventsDbContextOptionsBuilderExtensions | Events.EFCore | UseDomainEvents() extension |
If your entity cannot inherit from DomainEventSource (e.g., TPH/TPC hierarchy), implement IHasDomainEvents directly with a private List<IDomainEvent> field.
Handler Ordering
Section titled “Handler Ordering”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.
TimeProvider Integration
Section titled “TimeProvider Integration”Entities use TimeProvider for testable event timestamps:
// Production: uses system clockvar order = new Order();order.Place("Widget", 5); // OccurredAt = DateTimeOffset.UtcNow
// Testing: inject fake clock for deterministic timestampsvar 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:00Error Resilience
Section titled “Error Resilience”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.
Exception Handling (Continue on Failure)
Section titled “Exception Handling (Continue on Failure)”InMemoryEventDispatcher uses a continue-on-failure policy. If a handler throws, the exception is:
- Logged at
Errorlevel with the handler name and event name. - Recorded in the
HandlerFailuresmetric counter. - Recorded as an exception on the current
Activity(OTel trace). - Swallowed. The next handler still executes.
Handler A --> succeedsHandler B --> throws InvalidOperationException (logged, counted, continues)Handler C --> executes normallyOperationCanceledException 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.
Internal Call Context
Section titled “Internal Call Context”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.Actionsis not referenced,ICallContextresolves tonulland 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.
Observability
Section titled “Observability”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:
| Instrument | Type | Description |
|---|---|---|
pragmatic.events.dispatched | Counter | Total domain events dispatched |
pragmatic.events.dispatch_duration | Histogram (ms) | Duration of event dispatch (all handlers) |
pragmatic.events.handler_failures | Counter | Total event handler failures |
See docs/internals.md for the full logging output, tag names, and metric dimensions.
EF Core Interceptor Lifecycle
Section titled “EF Core Interceptor Lifecycle”The DomainEventsInterceptor extends SaveChangesInterceptor. It hooks into both SavedChangesAsync and SavedChanges (synchronous fallback).
Step-by-step flow:
SaveChangesAsync()executes and commits to the database.SavedChangesAsync()fires (post-commit).- Interceptor scans
ChangeTracker.Entries<IHasDomainEvents>()for entities with pending events. - Collects all events into a flat list, preserving entity traversal order.
- Clears events from entities before dispatch (prevents re-dispatch on retry).
- Dispatches the collected list via
IDomainEventDispatcher.DispatchAsync(IEnumerable<IDomainEvent>). - 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.
Cross-Boundary Events
Section titled “Cross-Boundary 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.
Testable Timestamps
Section titled “Testable Timestamps”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.
Outbox Pattern
Section titled “Outbox Pattern”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:
- Within the same transaction as your domain write, insert events into an
OutboxMessagestable. - A background worker polls the table and publishes events to a broker.
- Consumers process and acknowledge.
Testing
Section titled “Testing”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();}Related Packages
Section titled “Related Packages”| Package | Relationship |
|---|---|
Pragmatic.Abstractions | Defines the event interfaces |
Pragmatic.Ensure | Parameter guards used by dispatcher and interceptor |
Pragmatic.Actions | Domain action pipeline (handlers can invoke actions) |
Pragmatic.Persistence | Entity infrastructure (DomainEventSource entities) |
Pragmatic.Composition | SG-generated handler registration via [EventHandler] |
Requirements
Section titled “Requirements”- .NET 10.0 or later
Pragmatic.Abstractions(transitive)Pragmatic.Ensure(transitive)Microsoft.EntityFrameworkCore10.0+ (only forPragmatic.Events.EFCore)
License
Section titled “License”MIT