Command Pipeline
The ICommandPipeline service provides a way to execute commands programmatically, bypassing the HTTP layer. This is useful for scenarios where you need to execute commands from within your application code rather than through HTTP requests.
When to Use ICommandPipeline
Section titled “When to Use ICommandPipeline”The command pipeline is particularly useful for:
- Background services or scheduled tasks - Execute commands as part of scheduled jobs
- Event handlers - React to events by executing commands
- Internal service-to-service communication - Execute commands between services without HTTP overhead
- Testing scenarios - Execute commands directly in integration tests
- Saga or workflow orchestration - Coordinate multiple commands as part of a larger workflow
Basic Usage
Section titled “Basic Usage”ICommandPipeline provides two forms for every operation: a scope-free form that creates its own service scope automatically, and a scope-explicit form where you supply the IServiceProvider yourself.
Without a service provider (recommended for most cases)
Section titled “Without a service provider (recommended for most cases)”Inject ICommandPipeline and call Execute directly. The pipeline creates and disposes a dedicated service scope for each call — no manual scope management needed:
public class OrderProcessingService{ readonly ICommandPipeline _commandPipeline;
public OrderProcessingService(ICommandPipeline commandPipeline) { _commandPipeline = commandPipeline; }
public async Task ProcessOrder(Order order) { var result = await _commandPipeline.Execute(new ProcessOrderCommand(order.Id, order.Items));
if (result.IsSuccess) { // Command executed successfully } else { foreach (var error in result.ValidationResults) { // Process validation errors } } }}This is the right choice for background services, scheduled tasks, and any code that does not live inside an existing DI scope.
With a service provider (share an existing scope)
Section titled “With a service provider (share an existing scope)”If you are already inside a scoped lifetime — for example a Reactor, an event handler, or an HTTP endpoint — pass the current IServiceProvider so handler dependencies share the same scope as the caller:
public class OrderCreatedReactor{ readonly ICommandPipeline _commandPipeline; readonly IServiceProvider _serviceProvider;
public OrderCreatedReactor(ICommandPipeline commandPipeline, IServiceProvider serviceProvider) { _commandPipeline = commandPipeline; _serviceProvider = serviceProvider; }
public async Task Handle(OrderCreated @event) { var result = await _commandPipeline.Execute( new SendOrderConfirmation(@event.OrderId, @event.CustomerEmail), _serviceProvider); }}Command Results
Section titled “Command Results”The ICommandPipeline.Execute() method returns a CommandResult with comprehensive information about the execution:
var result = await _commandPipeline.Execute(command);
if (!result.IsAuthorized){ // Handle unauthorized access — the command was not executed}
if (result.IsSuccess){ // Command executed successfully}else{ // Handle validation errors foreach (var validationResult in result.ValidationResults) { // Process each validation error }}CommandResult Properties
Section titled “CommandResult Properties”| Property | Type | Description |
|---|---|---|
IsSuccess | bool | Whether the command executed successfully |
IsAuthorized | bool | Whether the user was authorized to execute the command |
IsValid | bool | Whether the command passed validation |
HasExceptions | bool | Whether any exceptions occurred during execution |
ValidationResults | IEnumerable<ValidationResult> | Validation errors if the command failed validation |
ExceptionMessages | IEnumerable<string> | Exception messages if exceptions occurred |
CorrelationId | CorrelationId | The correlation ID for tracking the command |
When using the generic Execute<TResult> overload, the returned CommandResult<TResult> adds one more property:
| Property | Type | Description |
|---|---|---|
Response | TResult? | The typed value returned by the command handler, or null if the command did not succeed or returned no value |
Exception Handling
Section titled “Exception Handling”When using ICommandPipeline programmatically, exceptions in the command handler are caught and returned as part of the CommandResult:
var result = await _commandPipeline.Execute(command);
if (result.HasExceptions){ // An exception was thrown during command execution foreach (var message in result.ExceptionMessages) { _logger.LogError("Command failed: {Message}", message); }}Validation Without Execution
Section titled “Validation Without Execution”The Validate method runs authorization and validation filters without invoking the command handler. It follows the same two forms as Execute.
Without a service provider:
var validationResult = await _commandPipeline.Validate(command);
if (validationResult.IsValid){ var result = await _commandPipeline.Execute(command);}With a service provider (to share an existing scope):
var validationResult = await _commandPipeline.Validate(command, _serviceProvider);
if (validationResult.IsValid){ var result = await _commandPipeline.Execute(command, _serviceProvider);}This is useful for pre-flight validation before committing to command execution.
Context and Authentication
Section titled “Context and Authentication”When executing commands programmatically, the current execution context (including user identity and claims) is automatically used. The command pipeline respects:
- Correlation ID - Automatically tracked for request tracing
- User context - The current user’s identity and claims are used for authorization
- Tenant context - Multi-tenancy context is preserved
If you need to execute commands under a different context, you’ll need to manage the authentication context appropriately in your application.
Background Service Example
Section titled “Background Service Example”Here’s an example of using ICommandPipeline in a background service:
public class OrderExpirationService : BackgroundService{ readonly ICommandPipeline _commandPipeline; readonly IOrderRepository _orderRepository; readonly ILogger<OrderExpirationService> _logger;
public OrderExpirationService( ICommandPipeline commandPipeline, IOrderRepository orderRepository, ILogger<OrderExpirationService> logger) { _commandPipeline = commandPipeline; _orderRepository = orderRepository; _logger = logger; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { var expiredOrders = await _orderRepository.GetExpiredOrders();
foreach (var order in expiredOrders) { // Each Execute call creates and disposes its own service scope var result = await _commandPipeline.Execute(new ExpireOrder(order.Id));
if (!result.IsSuccess) { _logger.LogWarning( "Failed to expire order {OrderId}: {Errors}", order.Id, string.Join(", ", result.ValidationResults.Select(v => v.Message))); } }
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } }}Event Handler Example
Section titled “Event Handler Example”Using ICommandPipeline in an event handler:
public class OrderCreatedEventHandler{ readonly ICommandPipeline _commandPipeline;
public OrderCreatedEventHandler(ICommandPipeline commandPipeline) { _commandPipeline = commandPipeline; }
public async Task Handle(OrderCreated @event) { // Send confirmation email when an order is created var command = new SendOrderConfirmation(@event.OrderId, @event.CustomerEmail); var result = await _commandPipeline.Execute(command);
if (!result.IsSuccess) { // Handle failure - maybe queue for retry } }}Typed Command Results
Section titled “Typed Command Results”When a command handler returns a value, use the generic Execute<TResult> overload to get back a CommandResult<TResult> with a strongly-typed Response property instead of working with object?. Both scope forms are available:
[Command]public record CreateOrder(IEnumerable<OrderItem> Items){ public OrderId Handle(IOrderService orderService) { return orderService.CreateOrder(Items); }}
// Without a service provider — pipeline creates its own scopevar result = await _commandPipeline.Execute<OrderId>(new CreateOrder(items));
// With a service provider — share the caller's scopevar result = await _commandPipeline.Execute<OrderId>(new CreateOrder(items), _serviceProvider);
if (result.IsSuccess){ // response is strongly typed — no cast required OrderId orderId = result.Response!; await NotifyCustomer(orderId);}The generic overload covers all the same failure paths as the non-generic one. When the command is unauthorized, fails validation, has no handler, or throws an exception, the result is still a valid CommandResult<TResult> — Response is just default:
var result = await _commandPipeline.Execute<OrderId>(new CreateOrder(items));
if (!result.IsAuthorized){ // result.Response is null — command was never executed}
if (!result.IsValid){ // result.Response is null — validation failed before execution}
if (result.HasExceptions){ // result.Response is null — an exception was thrown during execution}If the handler returns a different type from what you requested, an InvalidCastException is thrown. This is a programmer error — the type you pass to Execute<TResult> must match the type the command handler returns.
If the handler returns no value at all (a void-equivalent handler), Response is null. The non-generic Execute overload is equally valid in this case:
// Fine when you don't need a typed responsevar result = await _commandPipeline.Execute(new CancelOrder(orderId));