Skip to main content

Getting Started with Excalibur

This guide gets you up and running with Excalibur.Dispatch in under 5 minutes. By the end, you'll have a working message handler processing commands through the messaging pipeline.

Before You Start​

  • .NET 10.0
  • An IDE (Visual Studio, VS Code, or Rider)
  • Install the required packages:
    dotnet add package Excalibur.Dispatch

Step 1: Define an Action​

Actions are messages that trigger handlers. They can be commands (no return value) or queries (with return value).

using Excalibur.Dispatch;

// Action without return value
public record CreateOrderAction(string CustomerId, List<string> Items) : IDispatchAction;

// Action with return value
public record GetOrderAction(Guid OrderId) : IDispatchAction<Order>;

Step 2: Create a Handler​

Handlers process actions. Use IActionHandler<TAction> for commands or IActionHandler<TAction, TResult> for queries.

using Excalibur.Dispatch.Delivery;

// Handler for action without return value
public class CreateOrderHandler : IActionHandler<CreateOrderAction>
{
private readonly IOrderRepository _repository;

public CreateOrderHandler(IOrderRepository repository)
{
_repository = repository;
}

public async Task HandleAsync(
CreateOrderAction action,
CancellationToken cancellationToken)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = action.CustomerId,
Items = action.Items
};

await _repository.SaveAsync(order, cancellationToken);
}
}

// Handler for action with return value
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)
{
return await _repository.GetByIdAsync(action.OrderId, cancellationToken);
}
}

Step 3: Register Services​

Configure Excalibur.Dispatch in your Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Register Dispatch — auto-discovers handlers from the entry assembly
builder.Services.AddDispatch();

// Register your dependencies
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

var app = builder.Build();
A missing handler throws, it is not a failed result

Dispatching an action with no registered handler is a configuration fault, not a request outcome, so it throws a HandlerNotRegisteredException naming the action's full type name and both ways to register it — the same way IServiceProvider.GetRequiredService behaves for an unregistered service. It never arrives as a failed IMessageResult, so branching on Succeeded cannot mistake your missing registration for a bad request from your caller. Let it reach your error handler: unhandled, it surfaces as a 500 in development with the fix in the message.

It derives from InvalidOperationException, so an existing catch for that still catches it. It also passes through UseRetry(), UseExceptionMapping() and UseCircuitBreaker() untouched, so adding resilience to your pipeline does not turn the fault back into a failed result.

A failed IMessageResult therefore always means your handler ran and reported a failure.

Where handlers are discovered

AddDispatch() discovers handlers from your entry assembly — the project that contains Program.cs. If you keep handlers in a separate class library, name it explicitly: builder.Services.AddDispatch(typeof(CreateOrderHandler).Assembly);

Step 4: Dispatch Messages​

Inject IDispatcher and send messages. No explicit context is needed — the framework manages context automatically:

using Excalibur.Dispatch;

public class OrderController : ControllerBase
{
private readonly IDispatcher _dispatcher;

public OrderController(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}

[HttpPost]
public async Task<IActionResult> CreateOrder(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
var action = new CreateOrderAction(request.CustomerId, request.Items);

// No context needed - Dispatch creates one automatically
var result = await _dispatcher.DispatchAsync(action, cancellationToken);

if (result.Succeeded)
return Ok();

// A failed result means the handler ran and reported a failure. Do not map it to 400 by
// default: `result.ProblemDetails.Status` carries the status the framework determined.
return Problem(result.ErrorMessage, statusCode: result.ProblemDetails?.Status);
}

[HttpGet("{orderId}")]
public async Task<IActionResult> GetOrder(
Guid orderId,
CancellationToken cancellationToken)
{
var action = new GetOrderAction(orderId);

// TResponse (Order) is inferred from IDispatchAction<Order>
var result = await _dispatcher.DispatchAsync(action, cancellationToken);

if (result.Succeeded)
return Ok(result.ReturnValue);

return Problem(result.ErrorMessage, statusCode: result.ProblemDetails?.Status);
}
}

Complete Example​

Here's a complete minimal example. The one-line DispatchPostAction maps your action straight to an HTTP endpoint and converts the IMessageResult to the correct HTTP status for you — 200 on success, 400/403/500 on failure — so there is no manual Succeeded branching:

dotnet add package Excalibur.Dispatch.Hosting.AspNetCore
Program.cs
using Excalibur.Dispatch;
using Excalibur.Dispatch.Delivery;
using Excalibur.Dispatch.Hosting.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDispatch();

var app = builder.Build();

// Bind the request, dispatch the action, and map the result to the
// right HTTP status — all in one line. No manual result handling.
app.DispatchPostAction<GreetRequest, GreetAction, string>(
"/greet",
(request, _) => new GreetAction(request.Name));

app.Run();

// Action with return value
public record GreetAction(string Name) : IDispatchAction<string>;

// Handler
public class GreetHandler : IActionHandler<GreetAction, string>
{
public Task<string> HandleAsync(GreetAction action, CancellationToken ct)
{
return Task.FromResult($"Hello, {action.Name}!");
}
}

// Request DTO
public record GreetRequest(string Name);

This is the differentiator over the hand-rolled MapPost + DispatchAsync + manual Results.Ok/BadRequest branch shown in Step 4 — same behavior, one line, correct HTTP contract by default.

You Have a Working App — Stop Here Unless You Need More

The example above is a complete, production-ready Dispatch application. You don't need anything else to start building.

If you need...Next step
A hands-on walkthrough?Try the Order System Tutorial — build a complete API in 15 minutes.
Event sourcing?Continue to Event-Sourced Order System — add aggregates, event store, and projections.
Security and compliance?Continue to Secure Order System — add authorization, audit logging, and structured commands.
Just dispatching?You're done. See Dispatch Only for a focused reference.
Input validation?Add FluentValidation middleware
Event sourcing?Continue to Event Sourcing
Domain aggregates?Continue to Domain Modeling
Multi-step workflows?Continue to Sagas
Message broker?Continue to Transports

Key Concepts​

ConceptDescription
IDispatchActionBase interface for actions (commands without return value)
IDispatchAction<TResult>Base interface for actions with return value
IActionHandler<TAction>Handler for actions without return value
IActionHandler<TAction, TResult>Handler for actions with return value
IDispatcherCentral dispatcher for sending messages
IMessageContextContext for message metadata (correlation, tenant, etc.) - managed automatically
IMessageResultResult wrapper containing success/failure and errors

Context Management​

Excalibur.Dispatch automatically manages message context for you:

  • Top-level dispatch: A new root context is created with a unique CorrelationId
  • Nested dispatch: Call DispatchAsync from within a handler — it automatically propagates context by dispatching a child message
  • Ambient context: The current context is available via IMessageContextAccessor
// From a controller (top-level) - fresh root context created automatically
await _dispatcher.DispatchAsync(action, cancellationToken);

// From within a handler (nested) - automatically childs for proper tracing
await _dispatcher.DispatchAsync(action, cancellationToken);

See Handlers for more details on nested dispatch patterns.

Excalibur.Dispatch vs MediatR​

If you're coming from MediatR, here's how concepts map:

MediatRExcalibur.Dispatch
IRequestIDispatchAction
IRequest<TResponse>IDispatchAction<TResult>
IRequestHandler<TRequest>IActionHandler<TAction>
IRequestHandler<TRequest, TResponse>IActionHandler<TAction, TResult>
INotificationIDispatchEvent
INotificationHandler<T>IEventHandler<TEvent>
IMediatorIDispatcher

Adding More Packages​

Excalibur.Dispatch is the messaging core. Add more Excalibur packages as your architecture grows:

NeedPackage
Unified Excalibur builder (AddExcalibur)Excalibur.Hosting
Domain modeling (aggregates, entities)Excalibur.Domain
Event sourcing with persistenceExcalibur.EventSourcing
SQL Server event storeExcalibur.EventSourcing.SqlServer
Outbox patternExcalibur.Outbox
Change data captureExcalibur.Cdc
Sagas/process managersExcalibur.Saga
Leader electionExcalibur.LeaderElection
ASP.NET Core hosting integrationExcalibur.Hosting
# Add hosting (includes unified entry point for all subsystems)
dotnet add package Excalibur.Hosting

# Add domain modeling
dotnet add package Excalibur.Domain

# Add event sourcing
dotnet add package Excalibur.EventSourcing
dotnet add package Excalibur.EventSourcing.SqlServer

# Optional subsystems used by AddExcalibur(...).AddXxx(...)
dotnet add package Excalibur.Outbox
dotnet add package Excalibur.Cdc
dotnet add package Excalibur.Saga
dotnet add package Excalibur.LeaderElection

Unified Registration​

Use AddExcalibur() as the single entry point for domain, event sourcing, and saga subsystems. It registers messaging primitives with sensible defaults:

Feature methods are provided by their feature packages:

  • .AddEventSourcing(...) => Excalibur.EventSourcing
  • .AddOutbox(...) => Excalibur.Outbox
  • .AddCdc(...) => Excalibur.Cdc
  • .AddSagas(...) => Excalibur.Saga
  • .AddLeaderElection(...) => Excalibur.LeaderElection
builder.Services.AddExcalibur(excalibur =>
{
excalibur
.AddEventSourcing(es => es.UseEventStore<SqlServerEventStore>())
.AddOutbox(outbox => outbox.UseSqlServer(opts => opts.ConnectionString(connectionString)))
.AddCdc(cdc => cdc.TrackTable<Order>())
.AddSagas(saga => saga.WithCoordination().WithTimeouts())
.AddLeaderElection(le => le
.UseSqlServer(sql => sql.ConnectionString(connectionString))
.WithOptions(o => o.LeaseDuration = TimeSpan.FromSeconds(30)));
});

Need custom messaging configuration (transports, pipelines, middleware)? Call AddDispatch with a builder action:

// Configure Dispatch with transports and middleware (handlers auto-discovered)
builder.Services.AddDispatch(dispatch =>
{
dispatch.UseRabbitMQ(rmq => rmq.HostName("localhost"));
dispatch.UseObservability();
});

// Configure Excalibur subsystems
builder.Services.AddExcalibur(excalibur =>
{
excalibur
.AddEventSourcing(es => es.UseEventStore<SqlServerEventStore>())
.AddOutbox(outbox => outbox.UseSqlServer(opts => opts.ConnectionString(connectionString)));
});

Each subsystem binds its own options type from configuration, so you bind the section the subsystem actually reads rather than a single root object:

builder.Services.Configure<OutboxProcessingOptions>(
builder.Configuration.GetSection("Excalibur:Outbox"));

See the Package Guide for the complete package selection framework with migration paths and code examples.

Step 5: Add a Transport (Optional)​

The examples above dispatch messages in-process (no broker needed). When you're ready to send messages to a real broker, add a transport package and configure routing:

dotnet add package Excalibur.Dispatch.Transport.RabbitMQ
services.AddPluggableSerialization(); // Transports don't seat a default serializer

// Register the transport with destination mapping
services.AddRabbitMQTransport("rabbitmq", rmq =>
{
rmq.ConnectionString("amqps://guest:guest@localhost:5671/")
.MapQueue<CreateOrderAction>("orders-queue");
});

// Configure routing rules
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
dispatch.UseRouting(routing =>
{
routing.Transport
.Route<CreateOrderAction>().To("rabbitmq")
.Default("local"); // Keep unmatched messages in-process
});
});
Planning to publish Native AOT or trimmed?

AddHandlersFromAssembly finds handlers by scanning the assembly with reflection (Assembly.GetTypes(), then interface inspection). The trimmer cannot see a type that is only ever reached that way, so under PublishTrimmed=true or PublishAot=true a handler with no other static reference can be trimmed out of the app — and the failure is silent: the scan simply finds fewer handlers, and the messages they would have handled go unhandled at run time.

This call does not raise a build warning. The single-assembly overload carries explicit trim-analysis suppressions, so the toolchain stays quiet about a pattern it cannot actually verify. Do not read the absence of a warning as a guarantee that trimming is safe here.

The AOT-safe equivalent registers the same handlers explicitly, discovered at compile time by the source generator bundled in the Excalibur.Dispatch package — no reflection, and nothing for the trimmer to remove:

builder.Services.AddDispatch(dispatch => dispatch.AddDiscoveredHandlers());

Assembly scanning stays a fine choice for JIT-compiled apps, and it is the only option when handlers live in an assembly the generator does not compile (a third-party or dynamically loaded plugin). See Native AOT.

Your handlers don't change — only the registration code changes. See Choosing a Transport to pick the right broker and Message Routing for the full routing API.

StepPageWhat You'll Learn
1You are hereInstall Dispatch and create your first handler
2Your First EventEvents with multiple handlers
3SamplesBrowse working examples for your use case
4Core ConceptsPipelines, middleware, and context

Looking for a focused reference? See Dispatch Only for Dispatch without event sourcing or sagas.

What's Next​

Coming from Another Framework?​

If you're migrating from an existing messaging framework, start here:

See Also​