Notifications
Notifications implement a pub-sub pattern where multiple handlers can respond to a single notification.
v2 Changes
In v2, notification execution and error strategies are compile-time only. They are baked into the generated Publish_* methods, so there is no runtime branching. Two orthogonal per-type attributes control strategy:
[NotificationExecution(NotificationExecutionStrategy.Parallel)]
[NotificationError(NotificationErrorStrategy.ContinueAndAggregate)]
public record UserCreatedNotification(int UserId, string Email) : INotification;
Assembly-level attributes provide defaults for every notification type in the assembly:
[assembly: DefaultNotificationExecution(NotificationExecutionStrategy.Parallel)]
[assembly: DefaultNotificationError(NotificationErrorStrategy.ContinueAndAggregate)]
Precedence (resolved per strategy, per notification, at compile time):
- Per-notification attribute (
[NotificationExecution]/[NotificationError]) — wins when present. - Assembly-level default (
[assembly: DefaultNotificationExecution]/[assembly: DefaultNotificationError]). - Library defaults —
Sequentialfor execution,StopOnFirstErrorfor error.
⚠️ Hard break in v2: The old runtime notification-strategy properties,
NotificationOptionsAttribute, andISourceGeneratedMediator.GetNotificationOptionshave been removed. Use the attributes above.
Defining Notifications
public record UserCreatedNotification(int UserId, string Email) : INotification;
Creating Handlers
public class SendWelcomeEmailHandler : INotificationHandler<UserCreatedNotification>
{
public async ValueTask HandleAsync(UserCreatedNotification notification, CancellationToken ct = default)
{
await _emailService.SendWelcomeAsync(notification.Email);
}
}
public class CreateAuditLogHandler : INotificationHandler<UserCreatedNotification>
{
public async ValueTask HandleAsync(UserCreatedNotification notification, CancellationToken ct = default)
{
await _auditService.LogAsync($"User {notification.UserId} created");
}
}
Publishing Notifications
await mediator.PublishAsync(new UserCreatedNotification(user.Id, user.Email));
Execution Strategies (v2)
MediatorLite provides three execution strategies, selected via [NotificationExecution] (or the assembly-level default).
Sequential (Default)
Handlers execute one after another in order. Best for handlers with dependencies or when order matters.
[NotificationExecution(NotificationExecutionStrategy.Sequential)]
public record OrderCompletedNotification(int OrderId) : INotification;
Error Strategy Behavior:
| Error Strategy | Behavior |
|---|---|
StopOnFirstError | Stops execution immediately when a handler throws. Remaining handlers are not executed. |
ContinueAndAggregate | Continues executing all handlers. All exceptions are collected and thrown as AggregateException. |
Parallel
Parallel execution is cooperative ValueTask fan-out — not thread offload. The generated Publish_* method runs in two distinct phases. Reading it without that split in mind is what makes it look sequential.
[NotificationExecution(NotificationExecutionStrategy.Parallel)]
public record UserCreatedNotification(int UserId) : INotification;
1. Start phase — every handler’s HandleAsync is invoked before any result is awaited. Each call runs the handler body synchronously up to its first suspending await, then returns a ValueTask for the remainder. A handler that throws synchronously is captured into a faulted ValueTask, so one handler’s synchronous throw never stops the others from being started:
ValueTask vt1;
try { vt1 = h1.HandleAsync(notification, ct); }
catch (Exception ex) { vt1 = ValueTask.FromException(ex); }
ValueTask vt2;
try { vt2 = h2.HandleAsync(notification, ct); }
catch (Exception ex) { vt2 = ValueTask.FromException(ex); }
2. Await phase — the already-started ValueTasks are awaited in start order, collecting faults:
List<Exception>? exceptions = null;
try { await vt1.ConfigureAwait(false); }
catch (Exception ex) { (exceptions ??= new()).Add(ex); }
try { await vt2.ConfigureAwait(false); }
catch (Exception ex) { (exceptions ??= new()).Add(ex); }
Concurrency here is cooperative, not parallel threads. Handlers overlap only at their
awaitsuspension points. Two handlers whose bodies are fully synchronous — or that throw before anyawait— run their bodies back-to-back during the start phase (sequential in effect), because neither yields the thread. A handler with a realawait(I/O,Task.Delay, …) genuinely overlaps the others. MediatorLite never wraps handlers inTask.Run, which would cost a thread-pool hop and an allocation per handler.
Error Strategy Behavior:
[NotificationError] is honored in parallel — but it cannot un-start an in-flight handler, so every started handler is awaited to completion regardless of strategy. The strategy decides only which fault is surfaced:
| Error Strategy | Behavior |
|---|---|
ContinueAndAggregate | All faults are collected and thrown as a single AggregateException. A requested cancellation is rethrown as OperationCanceledException ahead of the aggregate. |
StopOnFirstError (default) | Every handler still runs to completion, but only the first fault (in handler order) is rethrown — unwrapped, preserving its original stack. |
StopOnFirst
Executes handlers in order until one completes successfully (“first handler wins”). Useful for fallback patterns.
[NotificationExecution(NotificationExecutionStrategy.StopOnFirst)]
public record CacheInvalidationNotification(string Key) : INotification;
Error Strategy Behavior:
| Error Strategy | Behavior |
|---|---|
StopOnFirstError | If a handler throws, stops immediately and propagates the exception. No fallback. |
ContinueAndAggregate | If a handler throws, tries the next handler. Stops on first success. If all handlers fail, throws AggregateException. |
Fallback Pattern Example:
[NotificationHandlerOrder(1)]
public class PrimaryCacheHandler : INotificationHandler<GetDataNotification> { }
[NotificationHandlerOrder(2)]
public class FallbackDatabaseHandler : INotificationHandler<GetDataNotification> { }
// Configure to try next handler on failure
[NotificationExecution(NotificationExecutionStrategy.StopOnFirst)]
[NotificationError(NotificationErrorStrategy.ContinueAndAggregate)]
public record GetDataNotification(string Key) : INotification;
Strategy Comparison
| Strategy | Order Matters | Stops Early | Error Strategy |
|---|---|---|---|
| Sequential | ✅ Yes | ❌ No | ✅ Applies |
| Parallel | Start order only | ❌ No | ✅ Applies (all vs. first fault) |
| StopOnFirst | ✅ Yes | ✅ On success | ✅ Applies |
Per-Notification Configuration (v2)
Apply one or both of the per-type attributes at compile time:
[NotificationExecution(NotificationExecutionStrategy.Parallel)]
[NotificationError(NotificationErrorStrategy.ContinueAndAggregate)]
public record HighPriorityNotification(string Message) : INotification;
Each attribute is independent: you can set only execution, only error, or both. Any strategy you do not set falls back — first to the assembly-level default (if declared), otherwise to the library default.
Assembly-Level Defaults (v2)
Declare defaults once per assembly instead of repeating the per-type attributes on every notification:
// AssemblyInfo.cs (or any file in the assembly)
[assembly: DefaultNotificationExecution(NotificationExecutionStrategy.Parallel)]
[assembly: DefaultNotificationError(NotificationErrorStrategy.ContinueAndAggregate)]
Both attributes are optional and independent. Per-type [NotificationExecution] / [NotificationError] always win when present.
Removed APIs (v2)
The following runtime APIs have been removed — they are replaced by the compile-time attributes above:
- The runtime
NotificationExecutionStrategy/NotificationErrorStrategyoptions (plus their containing options class). NotificationOptionsAttribute(replaced by split[NotificationExecution]+[NotificationError]).ISourceGeneratedMediator.GetNotificationOptions(Type)— strategies are now inlined intoPublish_*.
Handler Ordering
Control execution order with [NotificationHandlerOrder]:
[NotificationHandlerOrder(1)] // Executes first
public class PrimaryHandler : INotificationHandler<UserCreatedNotification> { }
[NotificationHandlerOrder(2)] // Executes second
public class SecondaryHandler : INotificationHandler<UserCreatedNotification> { }
Handlers without the attribute default to order 0.
Error Handling
try
{
await mediator.PublishAsync(notification);
}
catch (AggregateException ex)
{
// Multiple handlers threw exceptions
foreach (var inner in ex.InnerExceptions)
{
_logger.LogError(inner, "Handler failed");
}
}
Best Practices
- Use Sequential when handlers have dependencies or must run in order
- Use Parallel for independent handlers (emails, logging, analytics)
- Use StopOnFirst for fallback/circuit-breaker patterns
- Use
ContinueAndAggregatein production to ensure resilience - Handle exceptions in handlers to prevent cascade failures
Notification Inheritance
Dispatch matches the notification’s runtime type against the single most-specific handled type — it does not fan out across the type hierarchy:
- Publishing a
DerivedNotewhen onlyBaseNotehas handlers runs theBaseNotehandlers (the runtime type falls through to the base arm). - Publishing a
DerivedNotewhen bothDerivedNoteandBaseNotehave handlers runs only theDerivedNotehandlers; theBaseNotehandlers do not fire.
This mirrors the previous GetType()-keyed dispatch and is intentional. If a handler must observe both the base and the derived notification, register it for both types (one class can implement INotificationHandler<BaseNote> and INotificationHandler<DerivedNote>).