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.
Class Naming Conventions
Handler classes must end with one of these suffixes:
Handler
Consumer
// ✅ Valid handler class names
public class UserHandler { }
public class OrderHandler { }
public class EmailConsumer { }
public class NotificationConsumer { }
// ❌ Invalid - won't be discovered
public class UserService { }
public class OrderProcessor { }
Method Naming Conventions
Handler methods must use one of these names:
Handle
/HandleAsync
Handles
/HandlesAsync
Consume
/ConsumeAsync
Consumes
/ConsumesAsync
public class UserHandler
{
// ✅ All of these work
public User Handle(GetUser query) { }
public Task<User> HandleAsync(GetUser query) { }
public User Handles(GetUser query) { }
public Task<User> HandlesAsync(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 - new instance per mediator invocation
- 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 singleton by default. Constructor dependencies are resolved once and shared across all invocations.
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 (Singleton)
public class UserHandler
{
private readonly ILogger _logger; // ⚠️ Resolved once, shared across all calls
public UserHandler(ILogger<UserHandler> logger)
{
_logger = logger; // Singleton 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 MSBuild property to auto-register handlers:
<PropertyGroup>
<MediatorHandlerLifetime>Scoped</MediatorHandlerLifetime>
</PropertyGroup>
Options: None
(default), Singleton
, Scoped
, Transient
Handler Discovery Rules
Assembly Scanning
The source generator scans the current assembly for:
- Public classes ending with
Handler
orConsumer
- Public methods with valid handler names
- First parameter that defines the message type
Manual Handler Discovery
Handler classes can implement the IFoundatioHandler
interface for manual discovery:
public class UserProcessor : IFoundatioHandler
{
public User Handle(GetUser query) { } // ✅ Discovered
}
Handler classes and methods can be marked with the [FoundatioHandler]
attribute for manual discovery:
public class UserProcessor
{
[FoundatioHandler]
public User Process(GetUser query) { } // ✅ Discovered
}
Compile-Time Validation
The source generator provides compile-time errors for:
Missing Handlers
// ❌ Compile-time error if no handler exists
await mediator.InvokeAsync(new UnhandledMessage());
// Error: No handler found for message type 'UnhandledMessage'
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 called
Ignoring 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) { /* ... */ }
}
Next Steps
- Result Types - Robust error handling patterns
- Middleware - Cross-cutting concerns