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();AddPragmaticEventHandlers()already does this (viaTryAdd), so this step only applies when you register handlers by hand. Without a dispatcher the events are left on the entity rather than taken and dropped, soentity.DomainEventsgrowing across saves is the symptom that says this is your problem. -
Did you save through the unit of work? Dispatch belongs to whoever performed the write: the generated invoker of a mutation or action, the batch of a composition, or
EfCoreUnitOfWork. A call straight todbContext.SaveChangesAsync()bypasses all three: the rows are written and nothing is dispatched. -
Did you add the interceptor to your DbContext? Only matters for
[Raises<T>], which is what raises them:services.AddDbContext<AppDbContext>(options =>{options.UseNpgsql(connectionString);options.UseDomainEvents(); // LifecycleEventsInterceptor: raising only});Events raised by hand with
RaiseEvent(...)do not need it; declared ones need nothing else. -
Does your entity implement
IHasDomainEvents? The hand-over 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? Only tracked entities are scanned. An entity loaded
AsNoTracking()or detached takes its events with it. -
Did the save succeed? Events are taken only after a successful save. A refused write keeps its events on the entity on purpose: announcing a write that did not happen is worse than not announcing it.
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:
// SG-generated (requires the [EventHandler] attribute)services.AddPragmaticEventHandlers();// Or individuallyservices.AddDomainEventHandler<MyHandler, MyEvent>(); -
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’s assembly referenced by the host?
AddPragmaticEventHandlers()is generated per assembly. A handler in a module the host does not reference is never registered by anyone. -
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 framework handles this correctly by design: taking the events off the entity is what clears them, so there is no window in which the same event is both pending and already handed over. If you are dispatching by hand, follow the same pattern:
var events = entity.DomainEvents.ToList();entity.ClearDomainEvents(); // Clear BEFORE dispatchawait dispatcher.DispatchAsync(events, ct).ConfigureAwait(false);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.
Events Not Dispatching When Using Sync SaveChanges()
Section titled “Events Not Dispatching When Using Sync SaveChanges()”You call the synchronous SaveChanges(), it commits, but no handlers fire.
Handlers are asynchronous, so dispatch is too, and IUnitOfWork exposes only SaveChangesAsync. A synchronous SaveChanges() on the DbContext goes around the unit of work entirely: the rows are written, the declared events are still raised by the interceptor, and nobody takes them. There is no GetAwaiter().GetResult() bridge on purpose: that pattern can deadlock in ASP.NET Core and other synchronization-context-bearing hosts.
Save through the unit of work, always asynchronously:
await unitOfWork.SaveChangesAsync(ct); // dispatches; dbContext.SaveChanges() does notIf a code path genuinely cannot go async and still needs events delivered, dispatch manually after the sync save (clear before dispatch; see “Events Dispatched Twice on Retry”), or route the write through an async path.
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. When you need at-least-once delivery and crash-safety, enable the transactional outbox: it ships in Pragmatic.Events.EFCore (namespace Pragmatic.Events.EFCore.Outbox), not Pragmatic.Messaging. See the outbox Quick Start in Getting Started. For cross-service messaging over a broker, add 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/pragmatic-design/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.
