|
| 1 | +# Events & Notifications |
| 2 | + |
| 3 | +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. |
| 4 | + |
| 5 | +## Publishing Events |
| 6 | + |
| 7 | +Use `PublishAsync` to broadcast a message to all matching handlers: |
| 8 | + |
| 9 | +```csharp |
| 10 | +await mediator.PublishAsync(new OrderCreated("ORD-001", DateTime.UtcNow)); |
| 11 | +``` |
| 12 | + |
| 13 | +**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. |
| 14 | + |
| 15 | +Any message type works — events don't require special interfaces: |
| 16 | + |
| 17 | +```csharp |
| 18 | +public record OrderCreated(string OrderId, DateTime CreatedAt); |
| 19 | +``` |
| 20 | + |
| 21 | +## Handling Events |
| 22 | + |
| 23 | +Any handler discovered by the source generator can handle published events. Multiple handlers can handle the same event: |
| 24 | + |
| 25 | +```csharp |
| 26 | +public class EmailHandler |
| 27 | +{ |
| 28 | + public Task HandleAsync(OrderCreated e, IEmailService email) |
| 29 | + => email.SendOrderConfirmationAsync(e.OrderId); |
| 30 | +} |
| 31 | + |
| 32 | +public class AuditHandler |
| 33 | +{ |
| 34 | + public void Handle(OrderCreated e, ILogger<AuditHandler> logger) |
| 35 | + => logger.LogInformation("Order {OrderId} created at {Time}", e.OrderId, e.CreatedAt); |
| 36 | +} |
| 37 | + |
| 38 | +public class InventoryHandler |
| 39 | +{ |
| 40 | + public Task HandleAsync(OrderCreated e, IInventoryService inventory) |
| 41 | + => inventory.ReserveItemsAsync(e.OrderId); |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +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. |
| 46 | + |
| 47 | +## The INotification Interface |
| 48 | + |
| 49 | +`INotification` is a built-in marker interface for classifying event types: |
| 50 | + |
| 51 | +```csharp |
| 52 | +public record OrderCreated(string OrderId, DateTime CreatedAt) : INotification; |
| 53 | +public record OrderShipped(string OrderId) : INotification; |
| 54 | +``` |
| 55 | + |
| 56 | +It's completely **optional** — plain records work fine with `PublishAsync`. But it's useful for: |
| 57 | + |
| 58 | +- **Self-documenting code** — makes it clear a type is an event, not a command or query |
| 59 | +- **Interface subscriptions** — subscribe to all notifications with `SubscribeAsync<INotification>()` |
| 60 | +- **Middleware filtering** — apply middleware only to notification types |
| 61 | + |
| 62 | +You can also define your own marker interfaces for more specific grouping: |
| 63 | + |
| 64 | +```csharp |
| 65 | +public interface IDispatchToClient { } |
| 66 | + |
| 67 | +public record OrderCreated(string OrderId) : INotification, IDispatchToClient; |
| 68 | +public record ProductUpdated(string ProductId) : INotification, IDispatchToClient; |
| 69 | +public record AuditEntry(string Action) : INotification; // Not dispatched to clients |
| 70 | +``` |
| 71 | + |
| 72 | +## Handler Execution Order |
| 73 | + |
| 74 | +When multiple handlers handle the same event, you can control the order they run: |
| 75 | + |
| 76 | +```csharp |
| 77 | +[Handler(Order = 1)] |
| 78 | +public class ValidationHandler |
| 79 | +{ |
| 80 | + public void Handle(OrderCreated evt) { /* Runs first */ } |
| 81 | +} |
| 82 | + |
| 83 | +[Handler(Order = 2)] |
| 84 | +public class InventoryHandler |
| 85 | +{ |
| 86 | + public void Handle(OrderCreated evt) { /* Runs second */ } |
| 87 | +} |
| 88 | + |
| 89 | +// No Order specified — runs last (default is int.MaxValue) |
| 90 | +public class NotificationHandler |
| 91 | +{ |
| 92 | + public void Handle(OrderCreated evt) { /* Runs last */ } |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +You can also express ordering relationships without numeric values: |
| 97 | + |
| 98 | +```csharp |
| 99 | +[Handler(OrderBefore = [typeof(NotificationHandler)])] |
| 100 | +public class InventoryHandler |
| 101 | +{ |
| 102 | + public void Handle(OrderCreated evt) { /* Runs before NotificationHandler */ } |
| 103 | +} |
| 104 | +``` |
| 105 | + |
| 106 | +See [Handler Conventions](./handler-conventions#handler-execution-order) for details on ordering and relative ordering. |
| 107 | + |
| 108 | +## Publish Strategies |
| 109 | + |
| 110 | +The default strategy (`ForeachAwait`) runs handlers sequentially and waits for each to complete. You can change this globally: |
| 111 | + |
| 112 | +| Strategy | Behavior | Use Case | |
| 113 | +|----------|----------|----------| |
| 114 | +| **`ForeachAwait`** (default) | Sequential, waits for each handler | Predictable ordering, reliable completion | |
| 115 | +| **`TaskWhenAll`** | Concurrent, waits for all to complete | Maximum throughput for independent handlers | |
| 116 | +| **`FireAndForget`** | Concurrent, returns immediately | Background work where you don't need completion guarantees | |
| 117 | + |
| 118 | +Configure via the assembly attribute: |
| 119 | + |
| 120 | +```csharp |
| 121 | +// Assembly attribute |
| 122 | +[assembly: MediatorConfiguration( |
| 123 | + NotificationPublishStrategy = NotificationPublishStrategy.TaskWhenAll)] |
| 124 | +``` |
| 125 | + |
| 126 | +::: warning |
| 127 | +`FireAndForget` swallows exceptions and handlers may outlive the caller. Use with caution. |
| 128 | +::: |
| 129 | + |
| 130 | +## Error Handling |
| 131 | + |
| 132 | +When a handler throws during `PublishAsync`, the behavior depends on the publish strategy: |
| 133 | + |
| 134 | +- **`ForeachAwait`** — remaining handlers still execute. After all handlers complete, an `AggregateException` is thrown containing all failures. |
| 135 | +- **`TaskWhenAll`** — all handlers run concurrently. Failures are collected and thrown as an `AggregateException`. |
| 136 | +- **`FireAndForget`** — exceptions are swallowed. |
| 137 | + |
| 138 | +This means a failing handler never prevents other handlers from running. |
| 139 | + |
| 140 | +## Cascading Events |
| 141 | + |
| 142 | +Instead of calling `PublishAsync` explicitly, handlers can return events as tuple values. The mediator automatically publishes the extra values: |
| 143 | + |
| 144 | +```csharp |
| 145 | +public class OrderHandler |
| 146 | +{ |
| 147 | + public (Result<Order>, OrderCreated?) Handle(CreateOrder command) |
| 148 | + { |
| 149 | + var order = CreateOrder(command); |
| 150 | + return (order, new OrderCreated(order.Id, DateTime.UtcNow)); |
| 151 | + } |
| 152 | +} |
| 153 | +``` |
| 154 | + |
| 155 | +The `OrderCreated` event is published automatically after the handler returns. See [Cascading Messages](./cascading-messages) for the full API including conditional events and multi-event tuples. |
| 156 | + |
| 157 | +## Dynamic Subscriptions |
| 158 | + |
| 159 | +For scenarios where you need to consume events **at runtime** rather than through static handlers — such as streaming to connected clients — use `SubscribeAsync`: |
| 160 | + |
| 161 | +```csharp |
| 162 | +await foreach (var evt in mediator.SubscribeAsync<OrderCreated>(cancellationToken)) |
| 163 | +{ |
| 164 | + Console.WriteLine($"Order created: {evt.OrderId}"); |
| 165 | +} |
| 166 | +``` |
| 167 | + |
| 168 | +Subscribe to an interface to receive all matching types: |
| 169 | + |
| 170 | +```csharp |
| 171 | +await foreach (var evt in mediator.SubscribeAsync<IDispatchToClient>(cancellationToken)) |
| 172 | +{ |
| 173 | + // Receives OrderCreated, ProductUpdated, etc. |
| 174 | +} |
| 175 | +``` |
| 176 | + |
| 177 | +Each subscriber gets its own buffered channel. Configure buffer behavior with `SubscriberOptions`: |
| 178 | + |
| 179 | +```csharp |
| 180 | +await foreach (var evt in mediator.SubscribeAsync<IDispatchToClient>( |
| 181 | + cancellationToken, new SubscriberOptions { MaxCapacity = 50 })) |
| 182 | +{ |
| 183 | + // ... |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +There is zero cost when nobody is subscribed — `PublishAsync` skips the subscription fan-out entirely. |
| 188 | + |
| 189 | +For combining dynamic subscriptions with SSE streaming endpoints, see [Streaming Handlers](./streaming-handlers#dynamic-subscriptions-with-subscribeasync). |
| 190 | + |
| 191 | +## Best Practices |
| 192 | + |
| 193 | +- **Use `PublishAsync` for events, `InvokeAsync` for commands/queries** — events go to many handlers, commands go to exactly one |
| 194 | +- **Keep events small and focused** — include only the data consumers need, not entire entities |
| 195 | +- **Use nullable tuple types for conditional cascading** — `(Result<Order>, OrderCreated?)` lets you skip publishing on error paths cleanly |
| 196 | +- **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 |
0 commit comments