Troubleshooting
Practical problem/solution guide for Pragmatic.Events. Each section covers a common issue, the likely causes, and the fix.
Events Not Dispatching After SaveChanges
Section titled “Events Not Dispatching After SaveChanges”Entities raise events, SaveChangesAsync() succeeds, but handlers never fire.
Checklist
Section titled “Checklist”-
Did you register the dispatcher?
builder.Services.AddInMemoryDomainEvents();Without this call,
IDomainEventDispatcheris not in the DI container. The interceptor will fail when it tries to resolve it. -
Did you add the interceptor to your DbContext?
services.AddDbContext<AppDbContext>((sp, options) =>{options.UseNpgsql(connectionString);options.UseDomainEvents(sp); // This adds the DomainEventsInterceptor});Note the
(sp, options)overload — the service provider parameter is required. Using the(options)overload withoutspmeans you cannot callUseDomainEvents(sp). -
Does your entity implement
IHasDomainEvents? The interceptor scansChangeTracker.Entries<IHasDomainEvents>(). If your entity inheritsDomainEventSource, this is handled. If you implement events manually, verify the entity implementsIHasDomainEvents. -
Are events actually being raised? Set a breakpoint in
RaiseEvent()or checkentity.DomainEvents.CountbeforeSaveChangesAsync(). If the list is empty, the entity is not raising events in its domain operation. -
Is the entity tracked by the ChangeTracker? The interceptor only sees entities tracked by EF Core. If you create an entity with
AsNoTracking()or detach it, the interceptor will not find its events.
Handlers Not Executing
Section titled “Handlers Not Executing”The dispatcher fires, but a specific handler never runs.
Checklist
Section titled “Checklist”-
Is the handler registered in DI? Check one of these registration methods:
// Individual (AOT-safe)services.AddDomainEventHandler<MyHandler, MyEvent>();// Assembly scan (not AOT-safe)services.AddDomainEventHandlersFromAssembly(typeof(MyHandler).Assembly);// SG-generated (requires [EventHandler] attribute)// Check that the generated AddPragmaticEventHandlers() is called -
Does the handler implement
IDomainEventHandler<T>for the correct event type? A handler forReservationConfirmedwill not fire forReservationCreated. Verify the generic type parameter matches the event being raised. -
Is the handler class
public? Assembly scanning withAddDomainEventHandlersFromAssemblyonly discovers public types. Internal handlers must be registered individually. -
Check the handler’s
Orderproperty. If a preceding handler throws anOperationCanceledException, the entire dispatch chain stops. Non-OperationCanceledExceptionexceptions do not block subsequent handlers, but cancellation does. -
Is the event type correct at dispatch time? The untyped batch dispatch (
DispatchAsync(IEnumerable<IDomainEvent>)) resolves handlers based on the runtime type of each event. If you upcast the event toIDomainEventbefore raising, the runtime type is preserved and dispatch works correctly.
Handler Failures Silently Swallowed
Section titled “Handler Failures Silently Swallowed”A handler throws, but the caller of SaveChangesAsync() does not see the exception.
This Is By Design
Section titled “This Is By Design”The InMemoryEventDispatcher uses a continue-on-failure strategy. Handler exceptions (except OperationCanceledException) are:
- Logged at
Errorlevel with the handler name and event name. - Counted in the
pragmatic.events.handler_failuresmetric. - Recorded as an exception on the current OTel
Activity. - Not propagated to the caller.
How to Detect Failures
Section titled “How to Detect Failures”- Logs: Look for
Error-level messages:"Handler {HandlerName} failed for event {EventName}". - Metrics: Monitor
pragmatic.events.handler_failurescounter, tagged byevent.nameandhandler.name. - Traces: In your distributed tracing UI, look for exceptions recorded on
Event.{EventName}activities.
If You Need Fail-Fast
Section titled “If You Need Fail-Fast”The continue-on-failure policy is intentional for independent side effects. If you need a side effect that must succeed or fail atomically with the domain operation, do not use an event handler. Instead:
- Include the logic directly in the domain action or service method.
- Use a single handler that combines the critical and non-critical side effects with its own error handling.
InvalidOperationException: IDomainEventDispatcher Not Registered
Section titled “InvalidOperationException: IDomainEventDispatcher Not Registered”System.InvalidOperationException: No service for type 'Pragmatic.Events.IDomainEventDispatcher' has been registered.AddInMemoryDomainEvents() was not called before the service provider was built.
builder.Services.AddInMemoryDomainEvents();This must appear before builder.Build(). If using Pragmatic.Composition with PragmaticApp.RunAsync(), the SG-generated host handles this automatically when the Events module is detected.
Events Dispatched Twice on Retry
Section titled “Events Dispatched Twice on Retry”After a transient database failure, the retry succeeds but event handlers fire twice for the same events.
You are dispatching events manually before clearing them from the entity, and the retry re-collects them.
The DomainEventsInterceptor handles this correctly by design: it clears events from entities before dispatching. If you are dispatching manually (without the EF Core interceptor), follow the same pattern:
var events = entity.DomainEvents.ToList();entity.ClearDomainEvents(); // Clear BEFORE dispatchawait dispatcher.DispatchAsync(events, ct).ConfigureAwait(false);DomainEventsInterceptor Not Resolving From DI
Section titled “DomainEventsInterceptor Not Resolving From DI”The UseDomainEvents(sp) call throws because the service provider does not contain IDomainEventDispatcher.
The AddDbContext overload you are using does not provide the service provider:
// Wrong: no service provider availableservices.AddDbContext<AppDbContext>(options =>{ options.UseDomainEvents(???); // Where does sp come from?});Use the (IServiceProvider, DbContextOptionsBuilder) overload:
services.AddDbContext<AppDbContext>((sp, options) =>{ options.UseNpgsql(connectionString); options.UseDomainEvents(sp);});Events Accumulating on Entities Without Dispatch
Section titled “Events Accumulating on Entities Without Dispatch”The entity’s DomainEvents list keeps growing across multiple operations, but handlers never fire.
Possible Causes
Section titled “Possible Causes”-
No interceptor registered. See “Events Not Dispatching After SaveChanges” above.
-
Entity not saved to database. If you modify an entity and raise events but never call
SaveChangesAsync(), the interceptor never fires. Events only dispatch on successful persistence. -
Entity created outside of EF Core tracking. If you
newan entity, raise events, but neverAddit to a DbSet or the ChangeTracker, the interceptor will not see it.
Ensure the entity is tracked and persisted:
var reservation = Reservation.Create(/* ... */);dbContext.Reservations.Add(reservation); // Now trackedawait dbContext.SaveChangesAsync(ct); // Interceptor firesHandlers Run But Authorization Blocks Downstream Actions
Section titled “Handlers Run But Authorization Blocks Downstream Actions”A handler calls a domain action via a boundary interface, but the action returns a ForbiddenError or UnauthorizedError.
The ICallContext is not being resolved, so the handler does not run as an internal call. Authorization filters see the HTTP user’s permissions (which may not include the permission needed by the handler’s action).
Possible Fixes
Section titled “Possible Fixes”-
Verify
Pragmatic.Actionsis referenced.ICallContextis registered by the Actions module. Without it, the dispatcher’sGetService<ICallContext>()returnsnulland internal call mode is not activated. -
Check that the boundary interface action uses the pipeline. If the action is invoked through
IDomainActionInvoker<T>(the normal path), the pipeline respectsIsInternalCall. Direct method calls bypass the pipeline entirely. -
Do not use a custom
IDomainEventDispatcherthat skips internal call context. TheInMemoryEventDispatchercallscallContext?.EnterInternalCall()for each handler. A custom dispatcher must do the same.
Sync SaveChanges Blocking
Section titled “Sync SaveChanges Blocking”The synchronous SaveChanges() path works but blocks the thread during event dispatch.
This Is By Design
Section titled “This Is By Design”The DomainEventsInterceptor.SavedChanges (sync) override calls the async dispatch path via .GetAwaiter().GetResult(). This is a synchronous-over-async bridge — it blocks the calling thread.
Recommendation
Section titled “Recommendation”Always use SaveChangesAsync() in async code paths (ASP.NET Core handlers, background services). The sync SaveChanges() fallback exists for legacy code paths and test scenarios where async is not available.
Can I have multiple handlers for the same event?
Section titled “Can I have multiple handlers for the same event?”Yes. Register as many IDomainEventHandler<T> implementations as needed. They all run for every dispatch of that event type, sorted by Order.
What happens if no handlers are registered for an event?
Section titled “What happens if no handlers are registered for an event?”Nothing. The dispatcher logs at Debug level: "No handlers registered for event {EventName}", sets the handler count to 0, and returns. No exception is thrown.
Can handlers raise new events?
Section titled “Can handlers raise new events?”Yes, but those events are not automatically dispatched. If a handler modifies an entity tracked by EF Core and raises new events, those events will be dispatched on the next SaveChangesAsync() call. If the handler does not save, the new events accumulate on the entity.
Can I test handlers without EF Core?
Section titled “Can I test handlers without EF Core?”Yes. Create the handler directly and call HandleAsync:
var handler = new ReservationConfirmedHandler(mockBillingActions);await handler.HandleAsync(new ReservationConfirmed(/* ... */));Or test with the full dispatcher:
var services = new ServiceCollection();services.AddLogging();services.AddInMemoryDomainEvents();services.AddSingleton<IDomainEventHandler<ReservationConfirmed>>(handler);var sp = services.BuildServiceProvider();var dispatcher = sp.GetRequiredService<IDomainEventDispatcher>();await dispatcher.DispatchAsync(new ReservationConfirmed(/* ... */));Is InMemoryEventDispatcher suitable for production?
Section titled “Is InMemoryEventDispatcher suitable for production?”Yes, for in-process side effects. It is the standard dispatcher for monolithic applications and single-process deployments. For distributed scenarios requiring at-least-once delivery, cross-service messaging, or retry semantics, pair it with an outbox pattern or wait for Pragmatic.Messaging.
Can I replace InMemoryEventDispatcher with a custom implementation?
Section titled “Can I replace InMemoryEventDispatcher with a custom implementation?”Yes. Implement IDomainEventDispatcher and register your implementation:
services.AddScoped<IDomainEventDispatcher, MyCustomDispatcher>();The EF Core interceptor depends on IDomainEventDispatcher, not on InMemoryEventDispatcher directly.
Getting Help
Section titled “Getting Help”- GitHub Issues: github.com/nicola-pragmatic/Pragmatic.Design/issues
- Showcase Examples: See the
Showcaseproject for working cross-boundary event implementations with Booking and Billing boundaries. - Internals Guide: See internals.md for dispatcher mechanics, handler ordering, and observability details.
- Concepts Guide: See concepts.md for architecture decisions and design rationale.