Events & Notifications
Events are one of the most powerful features of Foundatio Mediator. They let you build loosely coupled systems where code reacts to things happening elsewhere — without direct references between the producer and the consumers.
Publishing Events
Use PublishAsync to broadcast a message to all matching handlers:
await mediator.PublishAsync(new OrderCreated("ORD-001", DateTime.UtcNow));By default, PublishAsync waits for all handlers to complete before returning. This is a deliberate design choice — it means you can reliably add event handlers and know they will run to completion before the publishing code continues. Unlike fire-and-forget systems, you don't lose events or race against request lifetimes.
Any message type works — events don't require special interfaces:
public record OrderCreated(string OrderId, DateTime CreatedAt);Handling Events
Any handler discovered by the source generator can handle published events. Multiple handlers can handle the same event:
public class EmailHandler
{
public Task HandleAsync(OrderCreated e, IEmailService email)
=> email.SendOrderConfirmationAsync(e.OrderId);
}
public class AuditHandler
{
public void Handle(OrderCreated e, ILogger<AuditHandler> logger)
=> logger.LogInformation("Order {OrderId} created at {Time}", e.OrderId, e.CreatedAt);
}
public class InventoryHandler
{
public Task HandleAsync(OrderCreated e, IInventoryService inventory)
=> inventory.ReserveItemsAsync(e.OrderId);
}All three handlers run when OrderCreated is published. The publishing code doesn't know or care which handlers exist — you can add, remove, or reorder them without touching the publisher.
The INotification Interface
INotification is a built-in marker interface for classifying event types:
public record OrderCreated(string OrderId, DateTime CreatedAt) : INotification;
public record OrderShipped(string OrderId) : INotification;It's completely optional — plain records work fine with PublishAsync. But it's useful for:
- Self-documenting code — makes it clear a type is an event, not a command or query
- Interface subscriptions — subscribe to all notifications with
SubscribeAsync<INotification>() - Middleware filtering — apply middleware only to notification types
You can also define your own marker interfaces for more specific grouping:
public interface IDispatchToClient { }
public record OrderCreated(string OrderId) : INotification, IDispatchToClient;
public record ProductUpdated(string ProductId) : INotification, IDispatchToClient;
public record AuditEntry(string Action) : INotification; // Not dispatched to clientsHandler Execution Order
When multiple handlers handle the same event, you can control the order they run:
[Handler(Order = 1)]
public class ValidationHandler
{
public void Handle(OrderCreated evt) { /* Runs first */ }
}
[Handler(Order = 2)]
public class InventoryHandler
{
public void Handle(OrderCreated evt) { /* Runs second */ }
}
// No Order specified — runs last (default is int.MaxValue)
public class NotificationHandler
{
public void Handle(OrderCreated evt) { /* Runs last */ }
}You can also express ordering relationships without numeric values:
[Handler(OrderBefore = [typeof(NotificationHandler)])]
public class InventoryHandler
{
public void Handle(OrderCreated evt) { /* Runs before NotificationHandler */ }
}See Handler Conventions for details on ordering and relative ordering.
Publish Strategies
The default strategy (ForeachAwait) runs handlers sequentially and waits for each to complete. You can change this globally:
| Strategy | Behavior | Use Case |
|---|---|---|
ForeachAwait (default) | Sequential, waits for each handler | Predictable ordering, reliable completion |
TaskWhenAll | Concurrent, waits for all to complete | Maximum throughput for independent handlers |
FireAndForget | Concurrent, returns immediately | Background work where you don't need completion guarantees |
Configure via the assembly attribute:
// Assembly attribute
[assembly: MediatorConfiguration(
NotificationPublishStrategy = NotificationPublishStrategy.TaskWhenAll)]WARNING
FireAndForget swallows exceptions and handlers may outlive the caller. Use with caution.
Error Handling
When a handler throws during PublishAsync, the behavior depends on the publish strategy:
ForeachAwait— remaining handlers still execute. After all handlers complete, anAggregateExceptionis thrown containing all failures.TaskWhenAll— all handlers run concurrently. Failures are collected and thrown as anAggregateException.FireAndForget— exceptions are swallowed.
This means a failing handler never prevents other handlers from running.
Cascading Events
Instead of calling PublishAsync explicitly, handlers can return events as tuple values. The mediator automatically publishes the extra values:
public class OrderHandler
{
public (Result<Order>, OrderCreated?) Handle(CreateOrder command)
{
var order = CreateOrder(command);
return (order, new OrderCreated(order.Id, DateTime.UtcNow));
}
}The OrderCreated event is published automatically after the handler returns. See Cascading Messages for the full API including conditional events and multi-event tuples.
Dynamic Subscriptions
For scenarios where you need to consume events at runtime rather than through static handlers — such as streaming to connected clients — use SubscribeAsync:
await foreach (var evt in mediator.SubscribeAsync<OrderCreated>(cancellationToken))
{
Console.WriteLine($"Order created: {evt.OrderId}");
}Subscribe to an interface to receive all matching types:
await foreach (var evt in mediator.SubscribeAsync<IDispatchToClient>(cancellationToken))
{
// Receives OrderCreated, ProductUpdated, etc.
}Each subscriber gets its own buffered channel. Configure buffer behavior with SubscriberOptions:
await foreach (var evt in mediator.SubscribeAsync<IDispatchToClient>(
cancellationToken, new SubscriberOptions { MaxCapacity = 50 }))
{
// ...
}There is zero cost when nobody is subscribed — PublishAsync skips the subscription fan-out entirely.
For combining dynamic subscriptions with SSE streaming endpoints, see Streaming Handlers.
Best Practices
- Use
PublishAsyncfor events,InvokeAsyncfor commands/queries — events go to many handlers, commands go to exactly one - Keep events small and focused — include only the data consumers need, not entire entities
- Use nullable tuple types for conditional cascading —
(Result<Order>, OrderCreated?)lets you skip publishing on error paths cleanly - Stick with the default publish strategy unless you have a specific reason to change it — sequential execution with guaranteed completion is the safest default for a loosely coupled system