Skip to content

Command Module

A. Shafie edited this page Apr 21, 2025 · 4 revisions

Command Module

Core Concepts

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

Command Contracts

LiteBus provides two main interfaces for commands:

ICommand

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.

ICommand<TCommandResult>

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.

Command Handlers

Main Handlers

LiteBus provides handler interfaces matching the command types:

For void commands:

public class UpdateInventoryCommandHandler : ICommandHandler<UpdateInventoryCommand>
{
    public Task HandleAsync(UpdateInventoryCommand command, CancellationToken cancellationToken = default)
    {
        // Processing logic here
        return Task.CompletedTask;
    }
}

For result-producing commands:

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

Pre-handlers execute before the main handler, useful for validation, logging, or enriching commands:

Type-specific pre-handler:

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;
    }
}

Global pre-handler:

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

Post-handlers execute after the main handler, useful for notifications, cleanup, or additional processing:

Type-specific post-handler without result:

public class UpdateInventoryCommandNotifier : ICommandPostHandler<UpdateInventoryCommand>
{
    public Task PostHandleAsync(UpdateInventoryCommand command, object? messageResult, 
                              CancellationToken cancellationToken = default)
    {
        // Post-processing logic
        return Task.CompletedTask;
    }
}

Type-specific post-handler with result:

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;
    }
}

Global post-handler:

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

Error handlers catch and process exceptions thrown during command execution:

Type-specific error handler:

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;
    }
}

Global error handler:

public class GlobalCommandErrorHandler : ICommandErrorHandler
{
    public Task HandleErrorAsync(ICommand command, object? messageResult,
                               Exception exception, CancellationToken cancellationToken = default)
    {
        // Global error handling logic
        return Task.CompletedTask;
    }
}

Command Mediator

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);
}

Usage Examples

// 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");

Advanced Features

Execution Context

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;
    }
}

Command Validation

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.

Covariant Command Handling

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.

Generic Commands

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" }
});

Tag-Based Command Handling

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");

Handler Ordering

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.

Aborting Command Execution

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.

Polymorphic Handler Dispatch

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);
    }
}

Command Mediation Settings

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);

Best Practices

  1. Keep commands focused - Each command should represent a single action
  2. Use meaningful names - Name commands with verbs describing the action (e.g., CreateProduct, not Product)
  3. Implement validation - Use pre-handlers or validators to ensure command integrity
  4. Maintain immutability - Design commands as immutable objects with init-only properties
  5. Handle errors properly - Use error handlers for centralized exception handling
  6. Consider idempotency - Design commands to be safely retried if needed
  7. Use typed results - For commands that return results, use specific types rather than generic objects
  8. Separate concerns - Keep pre-handlers, post-handlers, and error handlers focused on their specific roles
Clone this wiki locally