Cascading Messages
Cascading messages is a powerful feature in Foundatio Mediator that allows handlers to automatically publish additional messages when they complete. This enables clean event-driven architectures and decoupled business logic.
How Cascading Works
When a handler returns a tuple, the mediator performs type matching to determine which value to return to the caller and which values to publish as cascading messages:
- Type Matching: The mediator finds the tuple element that matches the
requested return type from
Invoke<T> - Return to Caller: That matched element is returned to the caller
- Cascading Publishing: All remaining non-null tuple elements are automatically published as messages
public class OrderHandler{ public static (Result<Order>, OrderCreated, EmailNotification) Handle(CreateOrderCommand command) { var order = new Order { Id = Guid.NewGuid(), Email = command.Email }; // Return tuple with multiple values return ( Result<Order>.Created(order, $"/orders/{order.Id}"), // Matches Invoke<Result<Order>> new OrderCreated(order.Id, order.Email), // Auto-published new EmailNotification(order.Email, "Order Created") // Auto-published ); }}// Usage - Result<Order> is returned, events are publishedvar result = await mediator.InvokeAsync<Result<Order>>(new CreateOrderCommand("test@example.com"));// result contains the Result<Order> from the tuple// OrderCreated and EmailNotification are automatically published
Type Matching Examples
Exact Type Match:
// Handler returns (string, OrderCreated)public static (string, OrderCreated) Handle(GetOrderStatusQuery query){ return ("Processing", new OrderCreated(query.OrderId, "user@example.com"));}// Call with string return typevar status = await mediator.InvokeAsync<string>(new GetOrderStatusQuery("123"));// status = "Processing", OrderCreated is published
Interface/Base Class Matching:
// Handler returns (Result<Order>, OrderCreated)public static (Result<Order>, OrderCreated) Handle(CreateOrderCommand command){ var order = new Order(); return (Result<Order>.Created(order), new OrderCreated(order.Id, order.Email));}// Can call with base Result typevar result = await mediator.InvokeAsync<Result>(new CreateOrderCommand("test@example.com"));// Result<Order> matches Result, OrderCreated is published
Tuple Return Patterns
Basic Event Publishing
public static (Order, OrderCreated) Handle(CreateOrderCommand command){ var order = new Order { Email = command.Email }; var orderCreated = new OrderCreated(order.Id, order.Email); return (order, orderCreated); // OrderCreated will be published automatically}
Multiple Events
public static (Order, OrderCreated, CustomerUpdated, InventoryReserved) Handle(CreateOrderCommand command){ var order = new Order { Email = command.Email, ProductId = command.ProductId }; return ( order, // Response new OrderCreated(order.Id, order.Email), // Event 1 new CustomerUpdated(command.CustomerId), // Event 2 new InventoryReserved(command.ProductId, 1) // Event 3 );}
Conditional Event Publishing
Use nullable tuple items to conditionally publish cascaded events. Any null
cascade item is skipped:
public static (Result<Order>, OrderCreated?, CustomerWelcomeEmail?) Handle(CreateOrderCommand command){ var order = new Order { Email = command.Email }; // Only publish welcome email for new customers var isNewCustomer = CheckIfNewCustomer(command.Email); return ( order, new OrderCreated(order.Id, order.Email), // Always published isNewCustomer ? new CustomerWelcomeEmail(command.Email) : null // Conditional );}```csharppublic static (Result<Order>, OrderCreated?) Handle(CreateOrderCommand command){ if (!IsValid(command)) return (Result.Conflict("Invalid order"), null); // No event published var order = new Order { Email = command.Email }; return (order, new OrderCreated(order.Id, order.Email)); // Event published}
Real-World Example
Here’s a complete e-commerce order processing example:
Messages
// Commandspublic record CreateOrderCommand(string Email, string ProductId, int Quantity);// Eventspublic record OrderCreated(string OrderId, string Email, DateTime CreatedAt);public record InventoryReserved(string ProductId, int Quantity);public record PaymentProcessed(string OrderId, decimal Amount);public record CustomerNotified(string Email, string Subject, string Message);
Order Handler with Cascading
public class OrderHandler{ public static (Result<Order>, OrderCreated, InventoryReserved, CustomerNotified) Handle( CreateOrderCommand command, IOrderRepository repository, ILogger<OrderHandler> logger) { logger.LogInformation("Creating order for {Email}", command.Email); var order = new Order { Id = Guid.NewGuid().ToString(), Email = command.Email, ProductId = command.ProductId, Quantity = command.Quantity, Status = OrderStatus.Created, CreatedAt = DateTime.UtcNow }; repository.Save(order); // Return response + cascade events return ( Result<Order>.Created(order, $"/orders/{order.Id}"), new OrderCreated(order.Id, order.Email, order.CreatedAt), new InventoryReserved(order.ProductId, order.Quantity), new CustomerNotified(order.Email, "Order Confirmation", $"Order {order.Id} created") ); }}
Event Handlers
Each cascaded event can have its own handlers:
// Handle inventory reservationpublic class InventoryHandler{ public static async Task Handle(InventoryReserved @event, IInventoryService inventory) { await inventory.ReserveAsync(@event.ProductId, @event.Quantity); }}// Handle customer notificationspublic class NotificationHandler{ public static async Task Handle(CustomerNotified @event, IEmailService email) { await email.SendAsync(@event.Email, @event.Subject, @event.Message); }}// Handle order analyticspublic class AnalyticsHandler{ public static async Task Handle(OrderCreated @event, IAnalyticsService analytics) { await analytics.TrackOrderCreatedAsync(@event.OrderId, @event.Email); }}
Complex Cascading Scenarios
Workflow Orchestration
public class PaymentHandler{ public static (Result<Payment>, PaymentProcessed?, OrderShipped?, CustomerNotified?) Handle( ProcessPaymentCommand command, IPaymentService paymentService, IOrderService orderService) { var payment = paymentService.ProcessPayment(command.OrderId, command.Amount); if (payment.IsSuccessful) { var order = orderService.MarkAsPaid(command.OrderId); return ( payment, new PaymentProcessed(command.OrderId, command.Amount), order.IsReadyToShip ? new OrderShipped(command.OrderId) : null, new CustomerNotified(order.Email, "Payment Confirmed", "Thank you!") ); } return (Result<Payment>.Error("Payment failed"), null, null, null); }}
Saga Pattern Implementation
public class OrderSagaHandler{ public static (Result, ReservationRequested?, PaymentRequested?) Handle( StartOrderSagaCommand command, ISagaRepository sagaRepo) { var saga = new OrderSaga { OrderId = command.OrderId, State = SagaState.Started }; sagaRepo.Save(saga); return ( Result.Success(), new ReservationRequested(command.OrderId, command.ProductId), new PaymentRequested(command.OrderId, command.Amount) ); } public static (Result, OrderCompleted?) Handle( ReservationConfirmed @event, ISagaRepository sagaRepo) { var saga = sagaRepo.GetByOrderId(@event.OrderId); saga.MarkReservationComplete(); if (saga.IsComplete) { return (Result.Success(), new OrderCompleted(@event.OrderId)); } return (Result.Success(), null); }}
Advanced Patterns
Event Sourcing Integration
public class EventSourcedOrderHandler{ public static (Result<Order>, object[]) Handle( CreateOrderCommand command, IEventStore eventStore) { var events = new List<object> { new OrderCreated(command.OrderId, command.Email), new InventoryReserved(command.ProductId, command.Quantity) }; // Add conditional events if (command.IsFirstOrder) { events.Add(new FirstOrderBonus(command.Email)); } var order = Order.FromEvents(events); eventStore.SaveEvents(command.OrderId, events); // Return order + all events for publishing return (order, events.ToArray()); }}
Batch Processing
public class BatchOrderHandler{ public static (Result<Order[]>, object[]) Handle( ProcessOrderBatchCommand command, IOrderRepository repository) { var orders = new List<Order>(); var events = new List<object>(); foreach (var orderData in command.Orders) { var order = new Order(orderData); orders.Add(order); events.Add(new OrderCreated(order.Id, order.Email)); events.Add(new InventoryReserved(order.ProductId, order.Quantity)); } repository.SaveAll(orders); // Batch processing complete event events.Add(new BatchProcessingCompleted(command.BatchId, orders.Count)); return (orders.ToArray(), events.ToArray()); }}
Performance Considerations
Inline vs Background Publishing
Cascaded messages are published inline by default, meaning:
- They execute in the same transaction/scope as the original handler
- They can affect the response time of the original request
- Failures in event handlers can impact the main operation
// This will execute all cascaded events before returningvar result = await mediator.Invoke(new CreateOrderCommand("user@example.com"));
Async Event Handlers
For better performance, make event handlers async:
public class EmailHandler{ public static async Task Handle(CustomerNotified @event, IEmailService email) { // This runs asynchronously but still inline await email.SendAsync(@event.Email, @event.Subject, @event.Message); }}
Best Practices
1. Keep Events Small and Focused
// Good: Focused eventspublic record OrderCreated(string OrderId, string Email);public record InventoryReserved(string ProductId, int Quantity);// Avoid: Large, multi-purpose eventspublic record OrderEvent(Order Order, Customer Customer, Product Product, /* ... */);
2. Use Nullable Types for Conditional Events
Always declare cascade event types as nullable (?) in tuple return
signatures. This lets you return null on error or conditional paths without
resorting to null!:
// ✅ Recommended: nullable event types — clean on both success and error pathspublic (Result<Order>, OrderCreated?) Handle(CreateOrder command){ if (!IsValid(command)) return (Result.Invalid("Bad order"), null); // No compiler warning var order = CreateOrder(command); return (order, new OrderCreated(order.Id, order.Email)); // Event published}// ❌ Avoid: non-nullable event types force null! on error pathspublic (Result<Order>, OrderCreated) Handle(CreateOrder command){ if (!IsValid(command)) return (Result.Invalid("Bad order"), null!); // Suppresses warning but defeats null safety // ...}
TIP
The mediator automatically skips publishing any null cascade item, so
nullable types have zero runtime cost — they only improve the developer
experience on error paths.
### 3. Limit Cascade Depth```csharp// Avoid deep cascading chains that are hard to follow// A -> B -> C -> D -> E -> F // Too deep!// Prefer: Flat event structures// A -> [B, C, D] // Better
4. Handle Failures Gracefully
public static (Result<Order>, OrderCreated?) Handle(CreateOrderCommand command){ try { var order = CreateOrder(command); return (order, new OrderCreated(order.Id)); } catch (Exception ex) { return (Result<Order>.Error(ex.Message), null); // No events on failure }}
5. Consider Using Result Types
public static (Result<Order>, OrderCreated?, OrderFailure?) Handle(CreateOrderCommand command){ if (command.IsValid) { var order = CreateOrder(command); return (order, new OrderCreated(order.Id), null); } return ( Result<Order>.Invalid("Invalid order data"), null, new OrderFailure(command.Email, "Validation failed") );}
Troubleshooting
Debugging Cascaded Events
Use structured logging to track event flow:
public class OrderHandler{ public static (Order, OrderCreated, InventoryReserved) Handle( CreateOrderCommand command, ILogger<OrderHandler> logger) { logger.LogInformation("Processing order creation for {Email}", command.Email); var events = ( new Order(command), new OrderCreated(Guid.NewGuid().ToString(), command.Email), new InventoryReserved(command.ProductId, command.Quantity) ); logger.LogInformation("Order handler will cascade {EventCount} events", 2); return events; }}
Event Handler Registration
Ensure all event handlers are discoverable:
// Make sure event handlers follow naming conventionspublic class InventoryHandler // Ends with 'Handler'{ public static async Task Handle(InventoryReserved @event) // Method named 'Handle' { // Handler implementation }}
Handler Discovery Scope
Important
Event handlers are only discovered in the current project and directly referenced projects. This is by design for performance - the source generator generates optimized dispatch code at compile time.
If your event handlers aren’t being called, check the project reference direction:
Common.Module (handlers here ARE called when Orders publishes) ↑Orders.Module (publishes events) ↑Api (handlers here are NOT called - wrong direction)
Solution: Place shared event handlers (like audit logging, notifications) in a common module that is referenced by all modules that publish events.
See the Troubleshooting Guide for more details.
Dynamic Subscriptions as an Alternative
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:
await foreach (var evt in mediator.SubscribeAsync<OrderCreated>(cancellationToken)){ Console.WriteLine($"Order created: {evt.OrderId}");}
Each subscriber gets its own buffered channel and can filter by concrete type or interface. See Dynamic Subscriptions for the full API.
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.