Skip to content

Commit 1753078

Browse files
committed
Doc updates
1 parent d39f1a0 commit 1753078

File tree

6 files changed

+221
-9
lines changed

6 files changed

+221
-9
lines changed

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default withMermaid(defineConfig({
4343
text: 'Core Concepts',
4444
items: [
4545
{ text: 'Handler Conventions', link: '/guide/handler-conventions' },
46+
{ text: 'Events & Notifications', link: '/guide/events-and-notifications' },
4647
{ text: 'Dependency Injection', link: '/guide/dependency-injection' },
4748
{ text: 'Result Types', link: '/guide/result-types' },
4849
{ text: 'Middleware', link: '/guide/middleware' },

docs/guide/cascading-messages.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,4 +524,17 @@ Api (handlers here are NOT called - wrong direction)
524524

525525
See the [Troubleshooting Guide](./troubleshooting.md#event-handlers-not-being-called) for more details.
526526

527+
## Dynamic Subscriptions as an Alternative
528+
529+
Cascading messages are handled by **static handlers** discovered at compile time. If you need to consume events **dynamically at runtime** — for example, streaming events to connected browser clients via SSE — use `SubscribeAsync` instead:
530+
531+
```csharp
532+
await foreach (var evt in mediator.SubscribeAsync<OrderCreated>(cancellationToken))
533+
{
534+
Console.WriteLine($"Order created: {evt.OrderId}");
535+
}
536+
```
537+
538+
Each subscriber gets its own buffered channel and can filter by concrete type or interface. See [Dynamic Subscriptions](./streaming-handlers#dynamic-subscriptions-with-subscribeasync) for the full API.
539+
527540
Cascading messages enable powerful event-driven architectures while maintaining clean, focused handler code. Use them to decouple business logic and create reactive systems that respond to domain events naturally.

docs/guide/endpoints.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public class EventStreamHandler(IMediator mediator)
6969
[EnumeratorCancellation] CancellationToken cancellationToken)
7070
{
7171
await foreach (var evt in mediator.SubscribeAsync<INotification>(
72-
cancellationToken: cancellationToken))
72+
cancellationToken))
7373
{
7474
yield return new ClientEvent(evt.GetType().Name, evt);
7575
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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

docs/guide/getting-started.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ public class AuditHandler
152152
await mediator.PublishAsync(new OrderCreated("ORD-001", DateTime.UtcNow));
153153
```
154154

155-
Handlers can even return cascading events as tuple resultssee [Cascading Messages](./cascading-messages).
155+
By default, `PublishAsync` waits for all handlers to completeso you can reliably add event handlers knowing they'll run before the publisher continues. See [Events & Notifications](./events-and-notifications) for the full story on publish strategies, error handling, and dynamic subscriptions.
156156

157157
### Dynamic Subscriptions
158158

@@ -164,7 +164,7 @@ public class EventStreamHandler(IMediator mediator)
164164
[HandlerEndpoint(Streaming = EndpointStreaming.ServerSentEvents)]
165165
public async IAsyncEnumerable<object> Handle(GetEventStream message, [EnumeratorCancellation] CancellationToken ct)
166166
{
167-
await foreach (var evt in mediator.SubscribeAsync<INotification>(cancellationToken: ct))
167+
await foreach (var evt in mediator.SubscribeAsync<INotification>(ct))
168168
yield return evt;
169169
}
170170
}

docs/guide/streaming-handlers.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public class EventStreamHandler(IMediator mediator)
6262
[EnumeratorCancellation] CancellationToken cancellationToken)
6363
{
6464
await foreach (var evt in mediator.SubscribeAsync<IDispatchToClient>(
65-
cancellationToken: cancellationToken))
65+
cancellationToken))
6666
{
6767
yield return new ClientEvent(evt.GetType().Name, evt);
6868
}
@@ -113,7 +113,7 @@ Foundatio Mediator supports **dynamic subscriptions** — receive published noti
113113
Call `mediator.SubscribeAsync<T>()` to create a subscription that yields every notification assignable to `T`:
114114

115115
```csharp
116-
await foreach (var evt in mediator.SubscribeAsync<OrderCreated>(cancellationToken: ct))
116+
await foreach (var evt in mediator.SubscribeAsync<OrderCreated>(ct))
117117
{
118118
Console.WriteLine($"Order created: {evt.OrderId}");
119119
}
@@ -131,7 +131,7 @@ public record OrderCreated(string OrderId) : IDispatchToClient;
131131
public record ProductUpdated(string ProductId) : IDispatchToClient;
132132

133133
// Receives both OrderCreated and ProductUpdated
134-
await foreach (var evt in mediator.SubscribeAsync<IDispatchToClient>(cancellationToken: ct))
134+
await foreach (var evt in mediator.SubscribeAsync<IDispatchToClient>(ct))
135135
{
136136
var eventType = evt.GetType().Name; // "OrderCreated" or "ProductUpdated"
137137
}
@@ -155,7 +155,7 @@ public class ClientEventStreamHandler(IMediator mediator)
155155
[EnumeratorCancellation] CancellationToken cancellationToken)
156156
{
157157
await foreach (var evt in mediator.SubscribeAsync<IDispatchToClient>(
158-
cancellationToken: cancellationToken))
158+
cancellationToken))
159159
{
160160
yield return new ClientEvent(evt.GetType().Name, evt);
161161
}
@@ -167,16 +167,18 @@ When any handler publishes a notification that implements `IDispatchToClient`, e
167167

168168
### Buffer Configuration
169169

170-
Each subscriber has a bounded buffer (default: 100 items). When full, the oldest unread item is dropped:
170+
Each subscriber has a bounded buffer (default: 100 items). When full, the oldest unread item is dropped. You can customize buffer behavior via `SubscriberOptions`:
171171

172172
```csharp
173173
await foreach (var evt in mediator.SubscribeAsync<IDispatchToClient>(
174-
maxCapacity: 10, cancellationToken: ct))
174+
ct, new SubscriberOptions { MaxCapacity = 10 }))
175175
{
176176
// ...
177177
}
178178
```
179179

180+
`SubscriberOptions` also exposes `FullMode` (`BoundedChannelFullMode`) to control what happens when the buffer is full — the default is `DropOldest`.
181+
180182
### Lifecycle
181183

182184
- **Subscribe:** `SubscribeAsync<T>()` registers the subscription immediately.

0 commit comments

Comments
 (0)