-
Notifications
You must be signed in to change notification settings - Fork 16
Polymorphic Dispatch
Polymorphic dispatch is an advanced feature of LiteBus that allows a single handler to process a hierarchy of related messages. By creating a handler for a base message type, you can automatically handle any message that derives from it.
Polymorphic dispatch leverages C# contravariance (in keyword) in handler interfaces. It allows you to write common, cross-cutting logic once and apply it to an entire family of commands, queries, or events.
Important: This feature applies to pre-handlers, post-handlers, and error handlers. It does not apply to main handlers, as they are resolved to the most specific message type to ensure a single, definitive handler for each message.
Consider a set of commands that all relate to auditable actions.
First, define a base class or interface that all related messages will implement.
// Base interface for all auditable commands
public interface IAuditableCommand : ICommand
{
Guid CorrelationId { get; }
string UserId { get; }
}
// Concrete commands that implement the base interface
public sealed class CreateProductCommand : IAuditableCommand, ICommand<Guid>
{
// ... properties
}
public sealed class DeleteUserCommand : IAuditableCommand, ICommand
{
// ... properties
}Now, create a single pre-handler that targets the base IAuditableCommand interface.
// This single pre-handler will run for ANY command implementing IAuditableCommand.
public sealed class AuditingPreHandler : ICommandPreHandler<IAuditableCommand>
{
private readonly IAuditLogger _auditLogger;
public AuditingPreHandler(IAuditLogger auditLogger)
{
_auditLogger = auditLogger;
}
public Task PreHandleAsync(IAuditableCommand command, CancellationToken cancellationToken = default)
{
// Log that an auditable action is about to occur.
_auditLogger.Log(
$"Audit: Action of type '{command.GetType().Name}' initiated by user '{command.UserId}'."
);
return Task.CompletedTask;
}
}When you send a derived command, LiteBus automatically discovers and executes the handler for the base type.
// Sending a CreateProductCommand...
await _commandMediator.SendAsync(new CreateProductCommand { ... });
// ...will trigger AuditingPreHandler.
// Sending a DeleteUserCommand...
await _commandMediator.SendAsync(new DeleteUserCommand { ... });
// ...will also trigger AuditingPreHandler.This behavior is enabled by the contravariant type parameter (in TMessage) on the handler interfaces:
// The 'in' keyword allows a handler for a base type to accept a derived type.
public interface IAsyncMessagePreHandler<in TMessage> { ... }
public interface IAsyncMessagePostHandler<in TMessage> { ... }When LiteBus resolves handlers, its ActualTypeOrFirstAssignableTypeMessageResolveStrategy finds handlers for both the concrete message type and any of its base types or implemented interfaces.
-
Auditing: Create a single post-handler for an
IAuditableinterface to log all state-changing operations. -
Authorization: Implement a single pre-handler for a
ISecuredOperationinterface to check user permissions for a family of related commands or queries. -
Validation: A pre-handler for a base
IPaginatedQuerycould validate that pagination parameters (PageNumber,PageSize) are within valid ranges for all queries that support pagination. -
Tenant Isolation: A pre-handler for an
ITenantSpecificinterface can ensure the request is scoped to the correct tenant.
- Define Clear Base Contracts: The base message interface or class should define the common data needed by the polymorphic handler.
- Use for Cross-Cutting Concerns: Polymorphic dispatch is ideal for logic that applies uniformly across a set of related messages, such as security, logging, or validation.
- Remember the Scope: This feature is for pre-handlers, post-handlers, and error handlers. Each concrete command, query, or event must still have its own specific main handler.
Both features let you write "write once, apply many" handlers, but they solve fundamentally different problems. Understanding when to use each — or both together — is key to a clean architecture.
| Aspect | Polymorphic Dispatch | Open Generic Handlers |
|---|---|---|
| Mechanism | C# contravariance (in TMessage). A handler for a base type accepts derived types. |
Generic type closing. LiteBus calls MakeGenericType at startup to create concrete handlers. |
| Targeting | A specific base type or interface (e.g., IAuditableCommand). |
All messages satisfying generic constraints (e.g., where T : ICommand). |
| Message requirements | Each message must explicitly implement the base interface. | No changes to messages required. Constraints are checked automatically. |
| Handler receives | The base type (e.g., IAuditableCommand). Can access shared properties directly. |
The concrete type (e.g., CreateProductCommand). No access to shared properties unless the handler casts or uses reflection. |
| Scope | Opt-in: only the subset of messages implementing the interface. | Opt-out: applies to all matching messages by default. |
| Granularity | Fine-grained — you control exactly which messages are affected by choosing which implement the interface. | Broad — everything matching the constraint is included unless you add narrower constraints. |
| DI registration | Standard: module.Register<AuditingPreHandler>(). |
Must use open generic syntax: module.Register(typeof(MyHandler<>)). |
| Runtime cost | Zero extra cost — standard assignability check during handler resolution. | Zero extra cost — closed handlers are cached at startup, identical to hand-written ones. |
| Best for | Behavior that depends on shared data from the base interface. | Behavior that applies universally and doesn't need shared properties. |
Use polymorphic dispatch when your handler needs to read or write properties defined on a shared interface. The handler receives the message typed as the base interface, giving direct access to those shared members.
// ✅ The handler needs CorrelationId and UserId — polymorphic dispatch is ideal.
public interface IAuditableCommand : ICommand
{
Guid CorrelationId { get; }
string UserId { get; }
}
public sealed class AuditingPreHandler : ICommandPreHandler<IAuditableCommand>
{
public Task PreHandleAsync(IAuditableCommand command, CancellationToken ct)
{
// Direct access to shared properties — no casting needed.
_logger.Log($"User {command.UserId} initiated {command.GetType().Name} (Correlation: {command.CorrelationId})");
return Task.CompletedTask;
}
}Good fit for:
- Auditing with
CorrelationId/UserId - Authorization by reading
ISecuredOperation.RequiredPermission - Tenant isolation using
ITenantSpecific.TenantId - Pagination validation on
IPaginatedQuery.PageSize
Use open generic handlers when the behavior does not depend on the message's shape and should apply to all (or most) messages of a given type.
// ✅ The handler only needs the type name — no shared interface needed.
public sealed class CommandLoggingPreHandler<T> : ICommandPreHandler<T>
where T : ICommand
{
public Task PreHandleAsync(T message, CancellationToken ct)
{
// Works for every command, regardless of its properties.
_logger.LogInformation("Executing: {Type}", typeof(T).Name);
return Task.CompletedTask;
}
}Good fit for:
- Logging / tracing every command or query
- Performance metrics and timing
- Transaction wrapping (
UnitOfWorkcommit/rollback) - FluentValidation integration (resolve
IValidator<T>from DI) - Idempotency checks (record/check a command ID)
Ask yourself these questions in order:
-
Does the handler need shared properties from the message?
- Yes → Use Polymorphic Dispatch. Define a base interface with those properties.
- No → Continue to 2.
-
Should the handler apply to all messages of this module, or only a specific subset?
-
All (or most) → Use an Open Generic Handler with a broad constraint (e.g.,
where T : ICommand). -
A specific subset → Use an Open Generic Handler with a narrow constraint (e.g.,
where T : ICommand, IAuditable) or use Polymorphic Dispatch on the subset interface.
-
All (or most) → Use an Open Generic Handler with a broad constraint (e.g.,
-
Do I want explicit opt-in or automatic opt-in?
- Explicit (each message must declare participation) → Use Polymorphic Dispatch.
- Automatic (applies unless constraints exclude it) → Use Open Generic Handler.
The two features complement each other. You can use open generic handlers for universal concerns and polymorphic dispatch for targeted behavior, all on the same message.
// --- Universal: applies to ALL commands (open generic) ---
public sealed class CommandLogger<T> : ICommandPreHandler<T> where T : ICommand
{
public Task PreHandleAsync(T message, CancellationToken ct)
{
Console.WriteLine($"[LOG] Executing: {typeof(T).Name}");
return Task.CompletedTask;
}
}
// --- Targeted: applies only to auditable commands (polymorphic dispatch) ---
public sealed class AuditTrailWriter : ICommandPostHandler<IAuditableCommand>
{
public Task PostHandleAsync(IAuditableCommand command, object? result, CancellationToken ct)
{
// Uses shared properties from the interface
_auditStore.Write(command.UserId, command.CorrelationId, command.GetType().Name);
return Task.CompletedTask;
}
}
// --- A message that participates in both ---
public sealed class CreateProductCommand : IAuditableCommand, ICommand<Guid>
{
public Guid CorrelationId { get; init; }
public string UserId { get; init; }
public required string Name { get; init; }
}When CreateProductCommand is sent, the pipeline executes:
1. CommandLogger<CreateProductCommand> ← open generic (universal)
2. CreateProductCommandHandler ← main handler
3. AuditTrailWriter ← polymorphic dispatch (targeted, reads UserId/CorrelationId)
See Generic Messages & Handlers — Open Generic Handlers for the full open generics documentation.