Notifications are dispatched to all registered handlers sequentially. Multiple handlers can subscribe to the same notification type, enabling decoupled event-driven architectures.
Design note: DSoftStudio.Mediator dispatches notifications by exact type (compile-time), not by inheritance hierarchy (runtime reflection). This is a deliberate design decision that avoids the MediatR duplicate handler bug.
public record UserCreated(Guid Id) : INotification;
public class SendWelcomeEmail : INotificationHandler<UserCreated>
{
public Task Handle(UserCreated notification, CancellationToken ct)
{
Console.WriteLine($"Sending welcome email to {notification.Id}");
return Task.CompletedTask;
}
}
public class AuditUserCreation : INotificationHandler<UserCreated>
{
public Task Handle(UserCreated notification, CancellationToken ct)
{
Console.WriteLine($"Audit: user {notification.Id} created");
return Task.CompletedTask;
}
}
Publish a notification:
await mediator.Publish(new UserCreated(userId));
Both SendWelcomeEmail and AuditUserCreation will execute, one after another. Handlers are resolved from DI, so each can have its own dependencies.
Do not depend on the order between handlers. The generated dispatch table and the generated container registrations are both ordered by handler type name, not by the order the registrations appear in. The sequence is deterministic — the two dispatch routes agree — but it is not the one your registration code suggests, and renaming a handler changes it. Work that must happen in a given sequence belongs in a single handler, or behind a request.
By default, handlers run sequentially — each one completes before the next starts — and if one
throws, the rest are skipped. To let them overlap, register the built-in
ParallelNotificationPublisher:
services.AddSingleton<INotificationPublisher, ParallelNotificationPublisher>();
| Strategy | Behavior |
|---|---|
| Sequential (default) | Handlers run one at a time. If a handler throws, subsequent handlers are not invoked. |
ParallelNotificationPublisher |
Queues every handler to the thread pool, then awaits them together with Task.WhenAll. |
Two things about the parallel publisher are worth knowing:
return Task.CompletedTask style. Shared state they touch needs synchronizing. The
cost of this is one thread-pool work item per handler; the default path has none.await surfaces one exception, not an AggregateException. Awaiting the task returned by
Publish rethrows the first faulted handler’s exception. Inspect Task.Exception on the
un-awaited task if you need them all. Every handler is started regardless, including after one
fails — a handler that throws synchronously does not stop the others.Registering any INotificationPublisher — including the built-in SequentialNotificationPublisher,
which reproduces the default semantics — bypasses the generated fast path. Handlers are then
resolved through IEnumerable<INotificationHandler<T>> instead of the generated concrete factories,
which costs a container lookup per publish and yields a different singleton instance for a handler
that keeps state.
That also changes which handlers run. The default path dispatches the table the generator built at
compile time; a publisher is handed whatever the container returns. A handler the generator could not
see — registered by hand against INotificationHandler<T> — is skipped by default and invoked once a
publisher is registered.
You can also implement INotificationPublisher for custom strategies (fire-and-forget, batched, prioritized, etc.):
public class FireAndForgetPublisher : INotificationPublisher
{
public Task Publish<TNotification>(
IEnumerable<INotificationHandler<TNotification>> handlers,
TNotification notification,
CancellationToken cancellationToken)
where TNotification : INotification
{
foreach (var handler in handlers)
_ = handler.Handle(notification, cancellationToken);
return Task.CompletedTask;
}
}