Skip to main content

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​

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​

  1. Use records - Immutability and equality come free
  2. Keep actions small - Only include data needed for the operation
  3. Use meaningful names - End with Action for clarity
  4. 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 IUnitOfWork or IDb — 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 CorrelationId is 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:

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:

For 90% of use cases, use the specialized handlers that return your business types directly:

InterfaceReturnsFramework Wraps To
IActionHandler<TAction>TaskIMessageResult
IActionHandler<TAction, TResult>Task<TResult>IMessageResult<TResult>
IEventHandler<TEvent>TaskIMessageResult
IDocumentHandler<TDocument>TaskIMessageResult

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:

CapabilityIActionHandlerIDispatchHandler
Return MessageResult.SuccessFromCache()❌✅
Set Disposition on result❌✅
Set ValidationResult on success❌✅
Set AuthorizationResult on success❌✅
Return failure without throwing exception❌✅
Access IMessageContext in handler❌✅
When in doubt, use specialized handlers

Start with IActionHandler or IEventHandler. Only switch to IDispatchHandler if you need the advanced capabilities above.

What's Next​

See Also​