Handler Conventions
Foundatio Mediator uses simple naming conventions to automatically discover handlers at compile time. This eliminates the need for interfaces, base classes, or manual registration while providing excellent compile-time validation.
Why Conventions?
Some developers initially feel that convention-based discovery is "too magical." But consider: all abstractions are magic until you learn them.
- ASP.NET discovers
*Controllerclasses automatically - Entity Framework treats
Idproperties as primary keys - xUnit runs methods decorated with
[Fact] - C# records generate equality,
ToString(), and more from a single line
The real question isn't "is it magic?" but "does the magic help or hurt?"
Convention-based handlers provide tangible benefits over traditional interface-based approaches:
| Benefit | Convention-Based | Interface-Based |
|---|---|---|
| Multiple handlers per class | ✅ Natural grouping | ❌ One interface per handler |
| Flexible method signatures | ✅ Any params via DI | ❌ Fixed Handle(TRequest, CancellationToken) |
| Sync or async | ✅ Return void, T, Task<T>, etc. | ❌ Must return Task<T> |
| Static handlers | ✅ Zero allocation | ❌ Always instance-based |
| Cascading messages | ✅ Tuple returns auto-publish | ❌ Manual IPublisher injection |
| Boilerplate | ✅ Just a class + methods | ❌ Interface inheritance per handler |
Prefer explicit declaration? Use IHandler or [Handler] attributes. See Explicit Handler Declaration.
Alternatively, you can mark handlers explicitly using the IHandler marker interface or the [Handler] attribute. See Explicit Handler Declaration for details.
Message Type Interfaces
Foundatio Mediator provides optional marker interfaces for messages. These enable type inference at call sites:
IRequest<TResponse>
The base interface for messages that return a response:
public record GetUser(int Id) : IRequest<User>;
// Type inference - no need to specify <User>
User user = await mediator.InvokeAsync(new GetUser(123));ICommand<TResponse>
For commands (operations that change state):
public record CreateUser(string Name, string Email) : ICommand<User>;
// Type inference works
User user = await mediator.InvokeAsync(new CreateUser("John", "john@example.com"));IQuery<TResponse>
For queries (read-only operations):
public record FindUsers(string SearchTerm) : IQuery<List<User>>;
// Type inference works
List<User> users = await mediator.InvokeAsync(new FindUsers("john"));Non-Generic Interfaces
For messages without responses, use the non-generic versions:
public record SendEmail(string To, string Body) : ICommand; // No response
public record LogEvent(string Message) : INotification; // Multiple handlersWhen to Use These Interfaces
These interfaces are optional. Use them when you want:
- Type inference at call sites (no need to specify
<TResponse>) - Self-documenting message intent (command vs query vs notification)
- Message classification — e.g., a handler can accept
INotificationto handle all notification types - Compatibility with MediatR-style patterns
Plain messages (like public record Ping(string Text);) work fine without any interface. In particular, INotification is not required for events to work with PublishAsync or cascading messages — it serves as a classification/self-documentation tool.
Class Naming Conventions
Handler classes must end with one of these suffixes:
Handler— the preferred convention for all handler classesConsumer— supported for convenience when migrating from other libraries (e.g., MassTransit), butHandleris recommended for new code
// ✅ Recommended
public class UserHandler { }
public class OrderHandler { }
public class EventHandler { }
// ✅ Also valid (migration convenience)
public class EmailConsumer { }
public class NotificationConsumer { }
// ❌ Invalid - won't be discovered
public class UserService { }
public class OrderProcessor { }Use Handler for Everything
Unlike some libraries that distinguish between "handlers" and "consumers," Foundatio Mediator treats both identically. The Consumer suffix exists purely to ease migration from other mediator and message bus libraries. For new projects, use Handler consistently for commands, queries, and events alike.
Method Naming Conventions
Handler methods must use one of these names:
Handle/HandleAsync— the preferred conventionHandles/HandlesAsync— alternative formConsume/ConsumeAsync— migration convenience (same as class suffix)Consumes/ConsumesAsync— migration convenience
public class UserHandler
{
// ✅ Recommended
public User Handle(GetUser query) { }
public Task<User> HandleAsync(GetUser query) { }
// ✅ Also valid (all are functionally identical)
public User Handles(GetUser query) { }
public User Consume(GetUser query) { }
// ❌ These won't be discovered
public User Process(GetUser query) { }
public User Get(GetUser query) { }
}Method Signature Requirements
First Parameter: The Message
The first parameter must be the message object:
public class OrderHandler
{
// ✅ Message as first parameter
public Order Handle(CreateOrder command) { }
// ❌ Message not first
public Order Handle(ILogger logger, CreateOrder command) { }
}Additional Parameters: Dependency Injection
All parameters after the first are resolved via dependency injection:
public class OrderHandler
{
public async Task<Order> HandleAsync(
CreateOrder command, // ✅ Message (required first)
IOrderRepository repository, // ✅ Injected from DI
ILogger<OrderHandler> logger, // ✅ Injected from DI
CancellationToken ct // ✅ Automatically provided
)
{
logger.LogInformation("Creating order for {CustomerId}", command.CustomerId);
return await repository.CreateAsync(command, ct);
}
}Supported Parameter Types
- Any registered service from the DI container
- CancellationToken - automatically provided by the mediator
- Scoped services - resolved from the current DI scope
- Singleton services - shared instance
Return Types
Handlers can return any type:
public class ExampleHandler
{
// ✅ Void (fire-and-forget)
public void Handle(LogMessage command) { }
// ✅ Task (async fire-and-forget)
public Task HandleAsync(SendEmail command) { }
// ✅ Value types
public int Handle(CalculateSum query) { }
// ✅ Reference types
public User Handle(GetUser query) { }
// ✅ Generic types
public Task<List<Order>> HandleAsync(GetOrders query) { }
// ✅ Result types
public Result<User> Handle(GetUser query) { }
// ✅ Tuples (for cascading messages)
public (User user, UserCreated evt) Handle(CreateUser cmd) { }
}Handler Types
Static Handlers
Simple, stateless handlers can be static:
public static class CalculationHandler
{
public static int Handle(AddNumbers query)
{
return query.A + query.B;
}
public static decimal Handle(CalculateTax query)
{
return query.Amount * 0.08m;
}
}Benefits:
- No DI registration required (but can be registered when it is desired to control handler class lifetime)
- Zero allocation for handler instance
- Clear that no state is maintained
Instance Handlers
For handlers requiring dependencies:
public class UserHandler
{
private readonly IUserRepository _repository;
private readonly ILogger<UserHandler> _logger;
// Constructor injection
public UserHandler(IUserRepository repository, ILogger<UserHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<User> HandleAsync(GetUser query)
{
_logger.LogInformation("Getting user {UserId}", query.Id);
return await _repository.GetByIdAsync(query.Id);
}
}Note: Handlers are internally cached by default (not registered with DI). Constructor dependencies are resolved once and shared across all invocations.
Open Generic Handlers
Handlers can be declared as open generic classes and will be automatically closed for the concrete message type at runtime. This lets you build reusable handler logic that applies to many message types.
// Generic command definitions
public record UpdateEntity<T>(T Entity);
public record UpdateRelation<TLeft, TRight>(TLeft Left, TRight Right);
// Open generic handler (single generic parameter)
public class EntityHandler<T>
{
public Task HandleAsync(UpdateEntity<T> command, CancellationToken ct)
{
// process update for entity of type T
return Task.CompletedTask;
}
}
// Open generic handler (two generic parameters)
public class RelationHandler<TLeft, TRight>
{
public Task HandleAsync(UpdateRelation<TLeft, TRight> command, CancellationToken ct)
{
return Task.CompletedTask;
}
}
// Usage
await mediator.InvokeAsync(new UpdateEntity<Order>(order));
await mediator.InvokeAsync(new UpdateRelation<User, Role>(user, role));Guidelines:
- The handler class, not the method, must be generic (generic handler methods are not currently supported).
- The message type must use the handler's generic parameters (e.g.,
UpdateEntity<T>inEntityHandler<T>). - Open generic handlers participate in normal invocation rules: exactly one match required for
Invoke / InvokeAsync; multiple open generics for the same message generic definition will cause an error when invoking (publish supports multiple).
Performance Notes:
- First invocation of a new closed generic combination incurs a small reflection cost; subsequent calls are cached.
- Static middleware resolution still applies and middleware can itself be generic.
If you need a custom behavior per entity type later, you can still add a concrete handler; the more specific (closed) handler will coexist.
Multiple Handlers in One Class
A single class can handle multiple message types:
public class OrderHandler
{
public Result<Order> Handle(CreateOrder command) { }
public Result<Order> Handle(GetOrder query) { }
public Result<Order> Handle(UpdateOrder command) { }
public Result Handle(DeleteOrder command) { }
}Handler Lifetime Management
Default Behavior (Internally Cached)
public class UserHandler
{
private readonly ILogger _logger; // ⚠️ Resolved once, shared across all calls
public UserHandler(ILogger<UserHandler> logger)
{
_logger = logger; // Cached dependency - OK
}
public User Handle(GetUser query, DbContext context) // ✅ Per-request dependency
{
// context is resolved fresh for each call
return context.Users.Find(query.Id);
}
}Explicit DI Registration
Control handler lifetime by registering in DI:
// Scoped handlers (new instance per request)
services.AddScoped<UserHandler>();
services.AddScoped<OrderHandler>();
// Transient handlers (new instance per use)
services.AddTransient<ExpensiveHandler>();Automatic DI Registration
Use the assembly attribute to auto-register handlers:
[assembly: MediatorConfiguration(HandlerLifetime = MediatorLifetime.Scoped)]Options: Default (default), Singleton, Scoped, Transient
Handler Discovery Rules
Assembly Scanning
The source generator scans the current assembly for:
- Public classes ending with
HandlerorConsumer - Public methods with valid handler names
- First parameter that defines the message type
Manual Handler Discovery
Handler classes can implement the IHandler interface for manual discovery:
public class UserProcessor : IHandler
{
public User Handle(GetUser query) { } // ✅ Discovered
}Handler classes and methods can be marked with the [Handler] attribute for manual discovery:
public class UserProcessor
{
[Handler]
public User Process(GetUser query) { } // ✅ Discovered
}Compile-Time Validation
The source generator provides compile-time errors for:
Multiple Handlers (for Invoke)
public class Handler1
{
public string Handle(DuplicateMessage msg) => "Handler1";
}
public class Handler2
{
public string Handle(DuplicateMessage msg) => "Handler2";
}
// ❌ Compile-time error
await mediator.InvokeAsync<string>(new DuplicateMessage());
// Error: Multiple handlers found for message type 'DuplicateMessage'Return Type Mismatches
public class UserHandler
{
public string Handle(GetUser query) => "Not a user"; // Returns string
}
// ❌ Compile-time error
var user = await mediator.InvokeAsync<User>(new GetUser(1));
// Error: Handler returns 'string' but expected 'User'Async/Sync Mismatches
public class AsyncHandler
{
public async Task<string> HandleAsync(GetMessage query)
{
await Task.Delay(100);
return "Result";
}
}
// ❌ Compile-time error - handler is async but calling sync method
var result = mediator.Invoke<string>(new GetMessage());
// Error: Async handler found but sync method calledIgnoring Handlers
Use [FoundatioIgnore] to exclude classes or methods:
[FoundatioIgnore] // Entire class ignored
public class DisabledHandler
{
public string Handle(SomeMessage msg) => "Ignored";
}
public class PartialHandler
{
public string Handle(Message1 msg) => "Handled";
[FoundatioIgnore] // Only this method ignored
public string Handle(Message2 msg) => "Ignored";
}Best Practices
1. Use Descriptive Handler Names
// ✅ Clear purpose
public class UserRegistrationHandler { }
public class OrderPaymentHandler { }
public class EmailNotificationConsumer { }
public class UserHandler { }
// ❌ Too generic
public class Handler { } // Handles what?2. Group Related Operations
// ✅ Cohesive handler
public class OrderHandler
{
public Result<Order> Handle(CreateOrder cmd) { }
public Result<Order> Handle(GetOrder query) { }
public Result<Order> Handle(UpdateOrder cmd) { }
public Result Handle(DeleteOrder cmd) { }
}
// ❌ Unrelated operations
public class MixedHandler
{
public User Handle(GetUser query) { }
public Order Handle(CreateOrder cmd) { }
public Email Handle(SendEmail cmd) { }
}3. Use Method Injection for Per-Request Dependencies
public class OrderHandler
{
private readonly ILogger _logger; // ✅ Singleton - safe for constructor
public OrderHandler(ILogger<OrderHandler> logger) => _logger = logger;
public async Task<Order> HandleAsync(
CreateOrder command,
DbContext context, // ✅ Scoped - use method injection
ICurrentUser user, // ✅ Per-request - use method injection
CancellationToken ct
)
{
// Fresh context and user for each request
}
}4. Keep Handlers Simple and Focused
// ✅ Single responsibility
public class CreateOrderHandler
{
public async Task<Result<Order>> HandleAsync(CreateOrder command)
{
// Only handles order creation
}
}
// ❌ Too many responsibilities
public class OrderHandler
{
public Result Handle(CreateOrder cmd) { /* ... */ }
public Result Handle(UpdateInventory cmd) { /* ... */ }
public Result Handle(SendEmail cmd) { /* ... */ }
public Result Handle(ProcessPayment cmd) { /* ... */ }
}Explicit Handler Declaration
In addition to naming conventions, handlers can be explicitly declared using:
- Interface - Classes implementing the
IHandlermarker interface - Attribute - Classes or methods decorated with
[Handler]
// Discovered via IHandler interface
public class OrderProcessor : IHandler
{
public Order Handle(CreateOrder command) { }
}
// Discovered via [Handler] attribute on class
[Handler]
public class EmailService
{
public void Handle(SendEmail command) { }
}
// Discovered via [Handler] attribute on method
public class NotificationService
{
[Handler]
public void Process(SendNotification command) { }
}Disabling Conventional Discovery
If you prefer explicit handler declaration over naming conventions, you can disable conventional discovery entirely:
[assembly: MediatorConfiguration(HandlerDiscovery = HandlerDiscovery.Explicit)]When set to Explicit, only handlers that implement IHandler or have the [Handler] attribute are discovered. Classes with names ending in Handler or Consumer will not be automatically discovered.
Handler Execution Order
When using PublishAsync to broadcast messages to multiple handlers, you can control the execution order using the Order property on the [Handler] attribute:
// Handlers execute in order: 1, 2, 3, then handlers without explicit order (default)
[Handler(Order = 1)]
public class HighPriorityHandler
{
public void Handle(OrderCreated evt)
{
// Executes first - e.g., validate order data
}
}
[Handler(Order = 2)]
public class MediumPriorityHandler
{
public void Handle(OrderCreated evt)
{
// Executes second - e.g., update inventory
}
}
[Handler(Order = 3)]
public class LowPriorityHandler
{
public void Handle(OrderCreated evt)
{
// Executes third - e.g., send notifications
}
}
// No Order specified - executes last (default is int.MaxValue)
public class DefaultOrderHandler
{
public void Handle(OrderCreated evt)
{
// Executes after all handlers with explicit Order
}
}Order Property Details
- Lower values execute first - A handler with
Order = 1executes beforeOrder = 10 - Default order is
int.MaxValue- Handlers without explicit order execute last - Only affects
PublishAsync-InvokeAsyncalways invokes exactly one handler - Two syntax options:
- Constructor argument:
[Handler(5)] - Named property:
[Handler(Order = 5)]
- Constructor argument:
Relative Ordering
Instead of managing numeric order values, you can express ordering relationships between handlers using OrderBefore and OrderAfter:
public record OrderCreated(string OrderId);
// "I must run before NotificationHandler"
[Handler(OrderBefore = [typeof(NotificationHandler)])]
public class InventoryHandler
{
public void Handle(OrderCreated evt)
{
// Update inventory first
}
}
// "I must run after InventoryHandler"
[Handler(OrderAfter = [typeof(InventoryHandler)])]
public class NotificationHandler
{
public void Handle(OrderCreated evt)
{
// Send notification after inventory is updated
}
}You can specify multiple types:
// This handler must run after both Validation and Inventory handlers
[Handler(OrderAfter = [typeof(ValidationHandler), typeof(InventoryHandler)])]
public class AuditHandler
{
public void Handle(OrderCreated evt) { }
}How relative ordering works:
OrderBefore = [typeof(X)]means "I run before X"OrderAfter = [typeof(X)]means "I run after X"- When no relative constraints exist between two handlers, numeric
Orderis used as a tiebreaker - Unknown types in
OrderBefore/OrderAfterare silently ignored
Circular Dependencies
If handlers form a circular dependency (e.g., A says OrderBefore B, and B says OrderBefore A), a compiler warning FMED012 is emitted and the cycle participants fall back to numeric Order sorting.
Use Cases
Handler ordering is useful for scenarios like:
- Validation before processing - Ensure validation handlers run first
- Audit logging - Record events before or after processing
- Cascading updates - Update parent entities before children
- Priority-based notifications - Send critical notifications first
public record PaymentReceived(string OrderId, decimal Amount);
[Handler(Order = 1)]
public class PaymentValidationHandler
{
public void Handle(PaymentReceived evt)
{
// First: Validate payment data
}
}
[Handler(Order = 2)]
public class OrderStatusHandler
{
public void Handle(PaymentReceived evt)
{
// Second: Update order status
}
}
[Handler(Order = 3)]
public class CustomerNotificationHandler
{
public void Handle(PaymentReceived evt)
{
// Third: Send customer confirmation
}
}
[Handler(Order = 100)]
public class AnalyticsHandler
{
public void Handle(PaymentReceived evt)
{
// Last (among explicit orders): Record analytics
}
}Next Steps
- Result Types - Robust error handling patterns
- Middleware - Cross-cutting concerns