-
Notifications
You must be signed in to change notification settings - Fork 11
Command Module
Commands in LiteBus represent requests to perform actions that change system state. They follow the Command pattern where each command:
- Has a single, specific purpose
- Is handled by exactly one handler
- Typically modifies system state
- May or may not return a result
LiteBus provides two main interfaces for commands:
public interface ICommand : IRegistrableCommandConstruct
{
}
Use this interface for commands that don't need to return a result. They perform an action and complete without returning data.
public interface ICommand<TCommandResult> : ICommand
{
}
Use this interface for commands that need to return data after execution. The type parameter TCommandResult
defines the return type.
LiteBus provides handler interfaces matching the command types:
public class UpdateInventoryCommandHandler : ICommandHandler<UpdateInventoryCommand>
{
public Task HandleAsync(UpdateInventoryCommand command, CancellationToken cancellationToken = default)
{
// Processing logic here
return Task.CompletedTask;
}
}
public class CreateProductCommandHandler : ICommandHandler<CreateProductCommand, ProductDto>
{
public Task<ProductDto> HandleAsync(CreateProductCommand command, CancellationToken cancellationToken = default)
{
// Processing logic here
return Task.FromResult(new ProductDto { /* ... */ });
}
}
Pre-handlers execute before the main handler, useful for validation, logging, or enriching commands:
public class CreateProductCommandValidator : ICommandPreHandler<CreateProductCommand>
{
public Task PreHandleAsync(CreateProductCommand command, CancellationToken cancellationToken = default)
{
// Validation logic
if (string.IsNullOrEmpty(command.Name))
throw new ValidationException("Product name is required");
return Task.CompletedTask;
}
}
public class AuditCommandPreHandler : ICommandPreHandler
{
public Task PreHandleAsync(ICommand command, CancellationToken cancellationToken = default)
{
// Logic applied to all commands
Console.WriteLine($"Command of type {command.GetType().Name} received at {DateTime.UtcNow}");
return Task.CompletedTask;
}
}
Post-handlers execute after the main handler, useful for notifications, cleanup, or additional processing:
public class UpdateInventoryCommandNotifier : ICommandPostHandler<UpdateInventoryCommand>
{
public Task PostHandleAsync(UpdateInventoryCommand command, object? messageResult,
CancellationToken cancellationToken = default)
{
// Post-processing logic
return Task.CompletedTask;
}
}
public class CreateProductCommandNotifier : ICommandPostHandler<CreateProductCommand, ProductDto>
{
public Task PostHandleAsync(CreateProductCommand command, ProductDto? result,
CancellationToken cancellationToken = default)
{
// Post-processing logic with access to the result
return Task.CompletedTask;
}
}
public class GlobalCommandLogger : ICommandPostHandler
{
public Task PostHandleAsync(ICommand command, object? messageResult,
CancellationToken cancellationToken = default)
{
// Logic applied to all commands after handling
return Task.CompletedTask;
}
}
Error handlers catch and process exceptions thrown during command execution:
public class CreateProductCommandErrorHandler : ICommandErrorHandler<CreateProductCommand>
{
public Task HandleErrorAsync(CreateProductCommand command, object? messageResult,
Exception exception, CancellationToken cancellationToken = default)
{
// Error handling logic for specific command
return Task.CompletedTask;
}
}
public class GlobalCommandErrorHandler : ICommandErrorHandler
{
public Task HandleErrorAsync(ICommand command, object? messageResult,
Exception exception, CancellationToken cancellationToken = default)
{
// Global error handling logic
return Task.CompletedTask;
}
}
The ICommandMediator
interface provides methods to send commands:
public interface ICommandMediator
{
Task SendAsync(ICommand command, CommandMediationSettings? commandMediationSettings = null,
CancellationToken cancellationToken = default);
Task<TCommandResult?> SendAsync<TCommandResult>(ICommand<TCommandResult> command,
CommandMediationSettings? commandMediationSettings = null,
CancellationToken cancellationToken = default);
}
// Sending a void command
await _commandMediator.SendAsync(new UpdateInventoryCommand { ProductId = 1, Quantity = 10 });
// Sending a command with result
var result = await _commandMediator.SendAsync(new CreateProductCommand { Name = "Product 1", Price = 9.99m });
// Sending a command with specific tag
await _commandMediator.SendAsync(new CreateProductCommand { Name = "Product 1" }, "PublicAPI");
LiteBus provides an ambient execution context that's available throughout the command handling process:
// Access execution context in any handler
var executionContext = AmbientExecutionContext.Current;
The execution context provides:
- Cancellation token: Access to the operation's cancellation token
- Data sharing: A dictionary to share data between handlers
- Flow control: Methods to abort execution
- Tag information: Access to the tags specified during command sending
Example of sharing data between handlers:
public class AuthenticationPreHandler : ICommandPreHandler<UpdateProductCommand>
{
public Task PreHandleAsync(UpdateProductCommand command, CancellationToken cancellationToken = default)
{
// Store user information for later handlers
AmbientExecutionContext.Current.Items["UserId"] = GetCurrentUserId();
return Task.CompletedTask;
}
}
public class AuditLogPostHandler : ICommandPostHandler<UpdateProductCommand>
{
public Task PostHandleAsync(UpdateProductCommand command, object? messageResult, CancellationToken cancellationToken = default)
{
// Retrieve user information from pre-handler
if (AmbientExecutionContext.Current.Items.TryGetValue("UserId", out var userId))
{
// Use the userId for audit logging
}
return Task.CompletedTask;
}
}
LiteBus provides the ICommandValidator<TCommand>
interface as a specialized pre-handler for validation:
public class CreateProductCommandValidator : ICommandValidator<CreateProductCommand>
{
public Task ValidateAsync(CreateProductCommand command, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(command.Name))
throw new ValidationException("Product name is required");
return Task.CompletedTask;
}
}
The validator is simply a specialized pre-handler that improves code readability by using a more domain-specific method name.
LiteBus supports covariance in command handling, allowing handlers to process derived command types:
// Base command
public class CreateFileCommand : ICommand
{
public string Name { get; set; }
}
// Derived command
public class CreateImageCommand : CreateFileCommand
{
public int Width { get; set; }
public int Height { get; set; }
}
// Handler for base command
public class CreateFileCommandHandler : ICommandHandler<CreateFileCommand>
{
public Task HandleAsync(CreateFileCommand command, CancellationToken cancellationToken = default)
{
// Handle any file creation
}
}
// Handler for derived command
public class CreateImageCommandHandler : ICommandHandler<CreateImageCommand>
{
public Task HandleAsync(CreateImageCommand command, CancellationToken cancellationToken = default)
{
// Handle image-specific creation
}
}
A CreateImageCommand
will be handled by CreateImageCommandHandler
even if cast to CreateFileCommand
.
LiteBus supports generic commands for operations that share behavior but operate on different data types:
public class LogActivityCommand<TPayload> : ICommand
{
public required TPayload Payload { get; init; }
public DateTime Timestamp { get; } = DateTime.UtcNow;
}
public class LogActivityCommandHandler<TPayload> : ICommandHandler<LogActivityCommand<TPayload>>
{
public Task HandleAsync(LogActivityCommand<TPayload> command, CancellationToken cancellationToken = default)
{
// Generic handling logic for any payload type
return Task.CompletedTask;
}
}
Usage example:
await _commandMediator.SendAsync(new LogActivityCommand<UserActivityPayload>
{
Payload = new UserActivityPayload { UserId = 123, Action = "Login" }
});
Use handler tags to conditionally execute handlers based on the execution context:
[HandlerTag("PublicAPI")]
public class StrictProductValidationHandler : ICommandPreHandler<CreateProductCommand>
{
// Stricter validation for public API
}
[HandlerTag("Internal")]
public class BasicProductValidationHandler : ICommandPreHandler<CreateProductCommand>
{
// Basic validation for internal use
}
You can also apply multiple tags to the same handler:
[HandlerTags("PublicAPI", "ExternalPartner")]
public class ExternalSecurityValidator : ICommandPreHandler<UpdateUserCommand>
{
// This handler executes for both PublicAPI and ExternalPartner contexts
}
// Or apply tags individually
[HandlerTag("PublicAPI")]
[HandlerTag("ExternalPartner")]
public class MultiContextValidator : ICommandPreHandler<UpdateUserCommand>
{
// Same as above, applies to both tags
}
Execute with specific tags:
// Using settings object
await _commandMediator.SendAsync(command, new CommandMediationSettings
{
Filters = { Tags = ["PublicAPI"] }
});
// Or using extension method
await _commandMediator.SendAsync(command, "PublicAPI");
Control the execution sequence of pre-handlers and post-handlers using the HandlerOrder
attribute:
// Pre-handlers execute in ascending order
[HandlerOrder(1)]
public class ValidationPreHandler : ICommandPreHandler<CreateProductCommand>
{
// Executes first
}
[HandlerOrder(2)]
public class EnrichmentPreHandler : ICommandPreHandler<CreateProductCommand>
{
// Executes second
}
// Post-handlers also execute in ascending order
[HandlerOrder(1)]
public class LoggingPostHandler : ICommandPostHandler<CreateProductCommand>
{
// Executes first after main handler
}
[HandlerOrder(2)]
public class NotificationPostHandler : ICommandPostHandler<CreateProductCommand>
{
// Executes second after main handler
}
If no order is specified, handlers default to order 0. Handlers with the same order value may execute in any sequence.
Command execution can be aborted during any phase (pre-handlers, main handler, post-handlers) using the execution context:
public class SecurityCommandPreHandler : ICommandPreHandler<UpdateInventoryCommand>
{
public Task PreHandleAsync(UpdateInventoryCommand command, CancellationToken cancellationToken = default)
{
if (!UserHasPermission())
{
// Abort command execution from pre-handler
AmbientExecutionContext.Current.Abort();
}
return Task.CompletedTask;
}
}
When aborting a command that returns a result, you must provide a result value:
public class CacheLookupPreHandler : ICommandPreHandler<GetProductCommand>
{
private readonly ICache _cache;
public CacheLookupPreHandler(ICache cache)
{
_cache = cache;
}
public Task PreHandleAsync(GetProductCommand command, CancellationToken cancellationToken = default)
{
var cachedResult = _cache.Get<ProductDto>(command.ProductId.ToString());
if (cachedResult != null)
{
// Abort command execution and return cached result
AmbientExecutionContext.Current.Abort(cachedResult);
}
return Task.CompletedTask;
}
}
The Abort
method throws a LiteBusExecutionAbortedException
which is handled internally by LiteBus, and the execution stops immediately.
LiteBus supports polymorphic handler dispatch in pre-handlers, post-handlers, and error handlers, allowing you to create handlers that process entire hierarchies of commands:
// Base command
public abstract class DocumentCommand : ICommand
{
public Guid DocumentId { get; set; }
public string UserId { get; set; }
}
// Derived commands
public class CreateDocumentCommand : DocumentCommand { /* ... */ }
public class UpdateDocumentCommand : DocumentCommand { /* ... */ }
public class DeleteDocumentCommand : DocumentCommand { /* ... */ }
// A single pre-handler that handles all document commands
public class DocumentAuthorizationPreHandler : ICommandPreHandler<DocumentCommand>
{
private readonly IAuthorizationService _authService;
public DocumentAuthorizationPreHandler(IAuthorizationService authService)
{
_authService = authService;
}
public async Task PreHandleAsync(DocumentCommand command, CancellationToken cancellationToken = default)
{
// This handler will be invoked for any command that inherits from DocumentCommand
if (!await _authService.CanAccessDocument(command.UserId, command.DocumentId))
{
throw new UnauthorizedAccessException("User does not have permission to access this document");
}
}
}
The same principle applies to post-handlers and error handlers:
// Post-handler for all document commands
public class DocumentAuditPostHandler : ICommandPostHandler<DocumentCommand>
{
private readonly IAuditLogger _auditLogger;
public DocumentAuditPostHandler(IAuditLogger auditLogger)
{
_auditLogger = auditLogger;
}
public Task PostHandleAsync(DocumentCommand command, object? messageResult,
CancellationToken cancellationToken = default)
{
// Log audit information for any document command
string action = command switch
{
CreateDocumentCommand => "Created",
UpdateDocumentCommand => "Updated",
DeleteDocumentCommand => "Deleted",
_ => "Accessed"
};
return _auditLogger.LogAsync(command.UserId, command.DocumentId, action);
}
}
CommandMediationSettings
allows you to control how commands are processed:
var settings = new CommandMediationSettings
{
Filters =
{
Tags = ["Tag1", "Tag2"] // Only handlers with these tags will execute
}
};
await _commandMediator.SendAsync(command, settings);
- Keep commands focused - Each command should represent a single action
-
Use meaningful names - Name commands with verbs describing the action (e.g.,
CreateProduct
, notProduct
) - Implement validation - Use pre-handlers or validators to ensure command integrity
- Maintain immutability - Design commands as immutable objects with init-only properties
- Handle errors properly - Use error handlers for centralized exception handling
- Consider idempotency - Design commands to be safely retried if needed
- Use typed results - For commands that return results, use specific types rather than generic objects
- Separate concerns - Keep pre-handlers, post-handlers, and error handlers focused on their specific roles
- Core Concepts
- Event Contracts
- Event Handlers
- Event Mediator/Publisher
- Advanced Features
- Best Practices
- Execution Context
- Handler Tags
- Handler Ordering
- Testing with LiteBus
- Performance Considerations
- Best Practices