-
Notifications
You must be signed in to change notification settings - Fork 16
Generic Messages and Handlers
LiteBus provides full support for generic messages and handlers, allowing you to create reusable components for operations that share a common structure but operate on different data types. There are two complementary patterns:
-
Generic Messages with Generic Handlers: The message itself is generic (e.g.,
CreateEntityCommand<TEntity>), and a matching generic handler processes it. Useful for CRUD and generic repository patterns. -
Open Generic Handlers: A handler is generic over the message type (e.g.,
GenericValidator<T> : ICommandPreHandler<T>). LiteBus automatically closes the generic at startup for every concrete message type that satisfies its constraints. Useful for cross-cutting concerns like validation, logging, and auditing that apply to many (or all) messages.
Generic messages and handlers use type parameters to define flexible contracts and logic. Instead of creating separate commands like CreateProductCommand and CreateUserCommand, you can create a single CreateEntityCommand<TEntity>.
Create a command, query, or event with one or more generic type parameters.
/// <summary>
/// A generic command for creating any entity, returning the entity's ID.
/// </summary>
public sealed class CreateEntityCommand<TEntity, TKey> : ICommand<TKey>
where TEntity : IEntity<TKey>
{
public required TEntity EntityData { get; init; }
}The handler must also be generic, with type parameters that match the message it handles.
/// <summary>
/// A generic handler for creating any entity.
/// </summary>
public sealed class CreateEntityCommandHandler<TEntity, TKey>
: ICommandHandler<CreateEntityCommand<TEntity, TKey>, TKey>
where TEntity : class, IEntity<TKey>
{
private readonly IRepository<TEntity, TKey> _repository;
public CreateEntityCommandHandler(IRepository<TEntity, TKey> repository)
{
_repository = repository;
}
public async Task<TKey> HandleAsync(CreateEntityCommand<TEntity, TKey> command, CancellationToken cancellationToken = default)
{
await _repository.AddAsync(command.EntityData, cancellationToken);
return command.EntityData.Id;
}
}When registering a generic handler with the dependency injection container, you must register its open generic type definition.
// In Program.cs
builder.Services.AddLiteBus(liteBus =>
{
liteBus.AddCommandModule(module =>
{
// Register the open generic type.
module.Register(typeof(CreateEntityCommandHandler<,>));
});
});When you send a generic message, you provide the concrete types. LiteBus and the DI container will automatically resolve and instantiate the correct closed generic handler (e.g., CreateEntityCommandHandler<Product, Guid>).
// In a service or controller
// Create a new Product
var newProduct = new Product { Name = "Laptop", Price = 1200 };
var productId = await _commandMediator.SendAsync(new CreateEntityCommand<Product, Guid>
{
EntityData = newProduct
});
// Create a new User
var newUser = new User { Username = "testuser" };
var userId = await _commandMediator.SendAsync(new CreateEntityCommand<User, int>
{
EntityData = newUser
});-
Generic CRUD Operations: Create a standard set of generic commands and queries (
CreateEntityCommand<T>,GetEntityByIdQuery<T>,UpdateEntityCommand<T>,DeleteEntityCommand<T>) for all your entities. -
Logging and Auditing: A generic
LogActivityCommand<TPayload>can be used to log different types of user activities with strongly-typed payloads. -
Data Synchronization: A generic
SyncDataCommand<TSource, TDestination>can handle data synchronization logic between different systems for various data types.
-
Register Open Generics: Open generic types (e.g.,
MyHandler<,>) are discovered automatically byRegisterFromAssembly. If the handler is in a different assembly, usemodule.Register(typeof(MyHandler<,>)). LiteBus handles the rest. -
Use Constraints: Apply generic constraints (
where T : ...) to your messages and handlers for type safety and better IntelliSense. -
Combine with Generic Repositories: This pattern works exceptionally well with a generic repository pattern (
IRepository<TEntity>), as shown in the example.
Open generic handlers allow you to write a single handler class that automatically applies to every concrete message type matching its generic constraints. This is the recommended approach for implementing cross-cutting pipeline concerns like validation, logging, authorization, and performance monitoring.
How is this different from Polymorphic Dispatch?
Polymorphic Dispatch Open Generic Handlers Mechanism Handler for a base type/interface (e.g., ICommandPreHandler<IAuditableCommand>) accepts derived messages via contravariance.Handler with a type parameter (e.g., ICommandPreHandler<T> where T : ICommand) is closed for each concrete message at startup.Message requirements Messages must implement the shared interface. No changes to messages; constraints are checked automatically. Handler receives The base type (direct access to shared properties). The concrete type (no shared properties unless cast). Best for Behavior that reads shared data ( UserId,TenantId,CorrelationId).Universal behavior that doesn't need shared data (logging, metrics, transactions). Rule of thumb: If your handler needs properties from a shared interface, use Polymorphic Dispatch. If it should apply to all messages regardless of shape, use Open Generic Handlers. They can also be combined; see the detailed comparison on the Polymorphic Dispatch page.
When you register an open generic handler (e.g., GenericValidator<>), LiteBus:
- Detects that the type parameter
TinICommandPreHandler<T>is an unbound generic parameter. - Stores the open generic handler definition.
- For every concrete message type already registered (or registered later), LiteBus checks if the message type satisfies the handler's generic constraints.
- If it does, LiteBus "closes" the generic (e.g.,
GenericValidator<CreateProductCommand>) and links the resulting handler to that message's pipeline.
This happens automatically; registration order does not matter.
/// <summary>
/// A cross-cutting pre-handler that logs every command before execution.
/// </summary>
public sealed class CommandLoggingPreHandler<TCommand> : ICommandPreHandler<TCommand>
where TCommand : ICommand
{
private readonly ILogger<CommandLoggingPreHandler<TCommand>> _logger;
public CommandLoggingPreHandler(ILogger<CommandLoggingPreHandler<TCommand>> logger)
{
_logger = logger;
}
public Task PreHandleAsync(TCommand message, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Executing command: {CommandType}", typeof(TCommand).Name);
return Task.CompletedTask;
}
}/// <summary>
/// A cross-cutting post-handler that records execution metrics for every command.
/// </summary>
public sealed class CommandMetricsPostHandler<TCommand> : ICommandPostHandler<TCommand>
where TCommand : ICommand
{
private readonly IMetricsService _metrics;
public CommandMetricsPostHandler(IMetricsService metrics)
{
_metrics = metrics;
}
public Task PostHandleAsync(TCommand message, object? messageResult, CancellationToken cancellationToken = default)
{
_metrics.RecordCommandExecuted(typeof(TCommand).Name);
return Task.CompletedTask;
}
}RegisterFromAssembly automatically discovers open generic handlers in the scanned assembly; no separate registration is needed.
builder.Services.AddLiteBus(liteBus =>
{
liteBus.AddCommandModule(module =>
{
// Assembly scanning picks up open generic AND concrete handlers alike
module.RegisterFromAssembly(typeof(Program).Assembly);
});
});If the open generic handler lives in a different assembly (e.g., a shared library), register it explicitly alongside assembly scanning:
builder.Services.AddLiteBus(liteBus =>
{
liteBus.AddCommandModule(module =>
{
// Explicit registration: handler is in an external library
module.Register(typeof(CommandLoggingPreHandler<>));
module.Register(typeof(CommandMetricsPostHandler<>));
// Scan this assembly for concrete handlers
module.RegisterFromAssembly(typeof(Program).Assembly);
});
});When you send any command, the open generic handlers are automatically part of the pipeline:
CreateProductCommand
1. CommandLoggingPreHandler<CreateProductCommand> (open generic, auto-closed)
2. CreateProductCommandHandler (concrete main handler)
3. CommandMetricsPostHandler<CreateProductCommand> (open generic, auto-closed)
No changes needed to existing commands or handlers; the open generic handlers are applied transparently.
Open generic handlers respect all standard C# generic constraints. A handler constrained to where T : ICommand will not apply to events or queries.
// This only applies to commands, not events or queries
public sealed class CommandValidator<T> : ICommandPreHandler<T>
where T : ICommand { /* ... */ }
// This only applies to types implementing your custom interface
public sealed class AuditHandler<T> : ICommandPostHandler<T>
where T : class, IAuditable, new() { /* ... */ }Supported constraints include:
- Interface constraints (
where T : IMyInterface) - Base class constraints (
where T : MyBaseClass) -
class/structconstraints -
new()constraint
Open generic handlers work regardless of registration order. All of the following are equivalent:
// Open generic first, then concrete handlers
module.Register(typeof(CommandLoggingPreHandler<>));
module.Register<CreateProductCommandHandler>();
// Concrete handler first, then open generic
module.Register<CreateProductCommandHandler>();
module.Register(typeof(CommandLoggingPreHandler<>));
// Assembly scanning finds both open generics and concrete handlers
module.RegisterFromAssembly(typeof(Program).Assembly);
// Explicit + assembly scanning (only needed if the handler is in a different assembly)
module.Register(typeof(CommandLoggingPreHandler<>)); // external assembly
module.RegisterFromAssembly(typeof(Program).Assembly);- Logging: Log every command/query execution with type and timing information.
- Validation: Apply a generic validation step (e.g., FluentValidation integration) to all commands.
- Performance Monitoring: Record execution time and metrics for every message in the pipeline.
- Authorization: Check access policies before any command is executed.
- Transaction Management: Wrap every command in a database transaction.
- Idempotency: Check and record command IDs to prevent duplicate processing.
-
Single type parameter: Open generic handlers are currently supported for handlers with a single generic type parameter. Multi-parameter open generics (e.g.,
MyHandler<TCommand, TResult>) are not yet supported for automatic closing.
Generic commands and events can be used with the durable inbox and outbox, but the durable contract must be closed and stable. A stored row needs one contract name, one version, and one concrete CLR type for deserialization.
public sealed record ArchiveCommand<TPayload>(TPayload Payload) : ICommand;
public sealed record ExportCompletedEvent<TPayload>(Guid ExportId) : IIntegrationEvent;
builder.Services.AddLiteBus(liteBus =>
{
liteBus.AddCommandInboxModule(inbox =>
{
inbox.Contracts.Register<ArchiveCommand<CustomerSnapshot>>(
"archive.commands.customer-snapshot",
version: 1);
});
liteBus.AddOutboxModule(outbox =>
{
outbox.Contracts.Register<ExportCompletedEvent<CustomerExport>>(
"exports.events.customer-export-completed",
version: 1);
});
});Do not register an open durable contract such as ArchiveCommand<>. Open generic messages do not map to a concrete deserialization type, so the registry rejects them at startup.
Use a distinct name for each closed durable message. Treat each name and version pair as persisted data, not as a reflection alias.
Open generic handlers and generic messages work together. For example, you can have a CreateEntityCommand<T> processed by a CreateEntityCommandHandler<T> (Pattern 1), while a CommandLoggingPreHandler<T> (Pattern 2) logs the execution for all commands, including generic ones.
-
Prefer
RegisterFromAssembly: Open generic handlers are discovered automatically by assembly scanning; no separateRegister(typeof(...))call is needed when the handler is in the scanned assembly. Use explicitmodule.Register(typeof(MyHandler<>))only for handlers in a different assembly. - Use Constraints Liberally: Apply the most specific constraints possible to limit open generic handlers to only the message types where they make sense.
- Prefer Open Generics over Repeated Registrations: If you find yourself creating the same pre/post handler for multiple message types, replace them with a single open generic handler.
-
Combine with [HandlerPriority]: Use
[HandlerPriority]to control where open generic handlers run relative to message-specific handlers in the pipeline. - Consider Performance: Open generic handlers are closed at startup, not at runtime. There is no additional runtime cost compared to explicitly registered handlers.