Actions and Handlers
Actions represent work to be done, and handlers contain the business logic to process them. This is the core pattern of Excalibur.Dispatch.
Before You Start
- .NET 10.0
- Install the required packages:
dotnet add package Excalibur.Dispatch
- Familiarity with dependency injection and getting started
Actions
An action is a simple data class that implements either IDispatchAction or IDispatchAction<TResult>.
Actions Without Return Value
Use IDispatchAction for commands that don't return data:
using Excalibur.Dispatch;
public record CreateOrderAction(
string CustomerId,
List<string> Items,
string? Notes = null) : IDispatchAction;
public record CancelOrderAction(Guid OrderId, string Reason) : IDispatchAction;
public record UpdateInventoryAction(string Sku, int Quantity) : IDispatchAction;
Actions With Return Value
Use IDispatchAction<TResult> for queries or commands that return data:
public record GetOrderAction(Guid OrderId) : IDispatchAction<Order>;
public record GetOrdersAction(string CustomerId) : IDispatchAction<IReadOnlyList<Order>>;
public record CreateOrderWithIdAction(
string CustomerId,
List<string> Items) : IDispatchAction<Guid>; // Returns the created order ID
Action Best Practices
- Use records - Immutability and equality come free
- Keep actions small - Only include data needed for the operation
- Use meaningful names - End with
Actionfor clarity - Validate in handlers - Actions are just data containers
// Good: Focused, immutable, clear name
public record ProcessPaymentAction(
Guid OrderId,
decimal Amount,
string Currency) : IDispatchAction<PaymentResult>;
// Avoid: Too much logic in the action itself
public record ProcessPaymentAction : IDispatchAction
{
public Guid OrderId { get; set; } // Mutable - avoid
public void Validate() { } // Logic in action - avoid
}
Handlers
Handlers contain the business logic that processes actions.
Action Handler Without Return
using Excalibur.Dispatch.Delivery;
public class CreateOrderHandler : IActionHandler<CreateOrderAction>
{
private readonly IOrderRepository _repository;
private readonly ILogger<CreateOrderHandler> _logger;
public CreateOrderHandler(
IOrderRepository repository,
ILogger<CreateOrderHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task HandleAsync(
CreateOrderAction action,
CancellationToken cancellationToken)
{
_logger.LogInformation("Creating order for customer {CustomerId}", action.CustomerId);
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = action.CustomerId,
Items = action.Items,
Notes = action.Notes,
CreatedAt = DateTimeOffset.UtcNow
};
await _repository.SaveAsync(order, cancellationToken);
_logger.LogInformation("Order {OrderId} created", order.Id);
}
}
Action Handler With Return
public class GetOrderHandler : IActionHandler<GetOrderAction, Order>
{
private readonly IOrderRepository _repository;
public GetOrderHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Order> HandleAsync(
GetOrderAction action,
CancellationToken cancellationToken)
{
var order = await _repository.GetByIdAsync(action.OrderId, cancellationToken);
if (order is null)
{
throw new OrderNotFoundException(action.OrderId);
}
return order;
}
}
Handler Registration
Handlers are discovered and registered automatically:
// Register all handlers from an assembly (recommended)
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});
// Or register handlers from multiple assemblies
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(CreateOrderHandler).Assembly);
dispatch.AddHandlersFromAssembly(typeof(GetOrderHandler).Assembly);
});
Handler Lifetime
By default, handlers are registered as transient services. This means:
- Handlers can safely depend on scoped services like
IUnitOfWorkorIDb— this does not require registering the handler itself as scoped. Dispatch decides whether a dependency-injection scope is needed by inspecting the handler's constructor dependency graph, so a transient handler that reaches a scoped service still gets a scope, in every host: ASP.NET Core, worker, console or serverless. - A handler that reaches no scoped dependency does not pay for a scope at all.
- State is not shared between dispatches, because a transient handler is a fresh instance each time. A stateless handler with no dependencies may be promoted to a shared instance as an optimisation; that promotion applies only to handlers with no instance state, so it is not observable.
To change the lifetime:
// Singleton handler (must be thread-safe)
services.AddSingleton<IActionHandler<GetConfigAction, Config>, GetConfigHandler>();
// Transient handler (new instance per resolution)
services.AddTransient<IActionHandler<ProcessAction>, ProcessHandler>();
Dispatching Actions
Inject IDispatcher to dispatch actions:
public class OrderService
{
private readonly IDispatcher _dispatcher;
public OrderService(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
public async Task CreateOrderAsync(
string customerId,
List<string> items,
CancellationToken cancellationToken)
{
var action = new CreateOrderAction(customerId, items);
var result = await _dispatcher.DispatchAsync(action, cancellationToken);
if (!result.Succeeded)
{
throw new InvalidOperationException(result.ErrorMessage);
}
}
public async Task<Order> GetOrderAsync(
Guid orderId,
CancellationToken cancellationToken)
{
var action = new GetOrderAction(orderId);
// TResponse (Order) is inferred from IDispatchAction<Order>
var result = await _dispatcher.DispatchAsync(action, cancellationToken);
return result.ReturnValue;
}
}
Nested Dispatch (Child Context)
When dispatching from within a handler, call DispatchAsync — it automatically propagates context by dispatching a child message:
public class CreateOrderHandler : IActionHandler<CreateOrderAction>
{
private readonly IDispatcher _dispatcher;
public async Task HandleAsync(CreateOrderAction action, CancellationToken ct)
{
// Create the order...
// Dispatch a child action - context is propagated automatically
var notifyAction = new NotifyOrderCreatedAction(orderId);
await _dispatcher.DispatchAsync(notifyAction, ct);
}
}
This ensures:
- The same
CorrelationIdis used - Distributed tracing spans are linked correctly
- Context items are available to child handlers
Event Handlers
For handling domain events or integration events.
An event declares the name it is stored and transmitted under with [MessageName]. That name is
the event's identity on the wire and in the event store, so it must not change when you rename or
move the type — see stable message names.
Publishing an event that declares no name throws InvalidOperationException as soon as something
needs to serialize it.
[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreatedEvent(Guid OrderId, string CustomerId) : IDispatchEvent;
public class OrderCreatedEventHandler : IEventHandler<OrderCreatedEvent>
{
public async Task HandleAsync(
OrderCreatedEvent @event,
CancellationToken cancellationToken)
{
// React to the event
}
}
Multiple handlers can subscribe to the same event, and every one of them is invoked:
public class SendEmailOnOrderCreated : IEventHandler<OrderCreatedEvent>
{
public Task HandleAsync(OrderCreatedEvent @event, CancellationToken cancellationToken)
{
// send the confirmation email
return Task.CompletedTask;
}
}
public class UpdateInventoryOnOrderCreated : IEventHandler<OrderCreatedEvent>
{
public Task HandleAsync(OrderCreatedEvent @event, CancellationToken cancellationToken)
{
// decrement stock
return Task.CompletedTask;
}
}
Handlers run independently: one failing does not stop the others.
Error Handling
Handlers can throw exceptions or return error results:
Using Exceptions (Recommended)
public class GetOrderHandler : IActionHandler<GetOrderAction, Order>
{
public async Task<Order> HandleAsync(GetOrderAction action, CancellationToken ct)
{
var order = await _repository.GetByIdAsync(action.OrderId, ct);
if (order is null)
{
throw new ResourceNotFoundException($"Order {action.OrderId} not found");
}
return order;
}
}
The exception mapping middleware converts exceptions to IMessageResult.Failed with proper problem details — once it is in the pipeline. Two separate things are involved: WithExceptionMapping() registers the mappers, and UseExceptionMapping() puts the middleware in the pipeline.
The presets already call UseExceptionMapping() for you — WithDefaults(), UseValidationStack(), UseDevelopmentMiddleware(), UseProductionMiddleware() and UseFullMiddleware(). If you use one of those, register your mappers and stop there; adding UseExceptionMapping() as well registers the middleware twice, and it will run twice on every message. If you build the pipeline yourself or from a pipeline profile, call both — no shipped profile includes it.
// Using a preset: the middleware is already in the pipeline.
services.AddDispatch(dispatch =>
{
dispatch.WithDefaults();
dispatch.WithExceptionMapping(mapping => mapping.Map<KeyNotFoundException>(
ex => MessageProblemDetails.NotFound(ex.Message)));
});
// Building the pipeline yourself: add the middleware as well.
services.AddDispatch(dispatch =>
{
dispatch.WithExceptionMapping(mapping => mapping.Map<KeyNotFoundException>(
ex => MessageProblemDetails.NotFound(ex.Message)));
dispatch.UseExceptionMapping(); // without this, the mappings are never consulted
});
Handler Interface Hierarchy
Dispatch provides two tiers of handler interfaces:
Recommended: Specialized Handlers
For 90% of use cases, use the specialized handlers that return your business types directly:
| Interface | Returns | Framework Wraps To |
|---|---|---|
IActionHandler<TAction> | Task | IMessageResult |
IActionHandler<TAction, TResult> | Task<TResult> | IMessageResult<TResult> |
IEventHandler<TEvent> | Task | IMessageResult |
IDocumentHandler<TDocument> | Task | IMessageResult |
Benefits:
- Clean handler code with no framework abstractions
- Framework wraps results automatically
- Exceptions converted to failures by middleware
Advanced: IDispatchHandler
For scenarios requiring direct control over IMessageResult, use IDispatchHandler<TMessage>:
public record GetOrderAction(Guid OrderId) : IDispatchMessage;
public class GetOrderHandler : IDispatchHandler<GetOrderAction>
{
private readonly IOrderRepository _repository;
public GetOrderHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<IMessageResult> HandleAsync(
GetOrderAction action,
IMessageContext context,
CancellationToken ct)
{
var order = await _repository.GetByIdAsync(action.OrderId, ct);
if (order is null)
{
return MessageResult.Failed(
MessageProblemDetails.NotFound(
$"Order with ID {action.OrderId} was not found"));
}
return MessageResult.Success(order);
}
}
When to Use IDispatchHandler
Use IDispatchHandler only when you need capabilities not available with specialized handlers:
| Capability | IActionHandler | IDispatchHandler |
|---|---|---|
Return MessageResult.SuccessFromCache() | ❌ | ✅ |
Set Disposition on result | ❌ | ✅ |
Set ValidationResult on success | ❌ | ✅ |
Set AuthorizationResult on success | ❌ | ✅ |
| Return failure without throwing exception | ❌ | ✅ |
Access IMessageContext in handler | ❌ | ✅ |
Start with IActionHandler or IEventHandler. Only switch to IDispatchHandler if you need the advanced capabilities above.
What's Next
- Message Context - Work with context metadata
- Results and Errors - Handle success and failure
- Handlers - Advanced handler patterns and registration
- Pipeline - Add behaviors around handlers
See Also
- Middleware — Add cross-cutting concerns like logging and exception mapping around handlers
- Validation Middleware — Automatically validate actions before they reach handlers
- Dependency Injection — Handler registration, lifetimes, and service injection patterns
- Error Handling Patterns — Dead-letter queues, retries, and resilient error handling strategies