Skip to main content

Message Routing

Dispatch provides a unified routing system that controls where messages go. Configure all routing through a single UseRouting() entry point with a fluent builder API.

Before You Start​

Three-Layer Architecture​

Getting a message from your code to a broker involves three distinct layers, each answering a different question:

LayerQuestionConfigured ViaExample
Transport"Which message bus?"routing.Transport.Route<T>().To("rabbitmq")"rabbitmq", "kafka", "local"
Destination"Which queue/topic on that bus?"builder.MapTopic<T>("orders.events")"orders.events", "payments-queue"
Endpoint"Which logical services receive this?"routing.Endpoints.Route<T>().To("billing")"billing-service", "inventory"
  • Transport is an infrastructure decision — which broker receives the message.
  • Destination is a physical address — the actual queue or topic name on that broker. This is configured per-transport during DI registration (see Where Does the Destination Come From? below).
  • Endpoint is a logical concern — which services should process the message. This is optional; if omitted, the transport delivers to its default destination.

IDispatchRouter combines transport selection and endpoint routing into a single RoutingDecision. Destination mapping is handled separately by each transport adapter.

Message Flow: From Dispatch to Broker​

When you call dispatcher.DispatchAsync(message, ct), here is the runtime resolution chain:

1. DispatchAsync(message)
│
2. ▼ RoutingMiddleware
│ Calls IDispatchRouter.RouteAsync()
│
3. ├── ITransportSelector.SelectTransportAsync()
│ → evaluates Route<T>().To() rules
│ → returns transport name: "rabbitmq"
│
├── IEndpointRouter.RouteToEndpointsAsync()
│ → returns endpoint names: ["billing-service"]
│ → (empty list if no endpoint rules configured)
│
▼
4. RoutingDecision { Transport="rabbitmq", Endpoints=["billing-service"] }
│ stored on context.RoutingDecision
│
5. ▼ TransportRegistry.GetTransportAdapter("rabbitmq")
│ → returns ITransportAdapter for that broker
│
6. ▼ ITransportAdapter creates ITransportSender
│ → Destination = "orders-queue" (from MapQueue/MapTopic config)
│
7. ▼ ITransportSender.SendAsync(transportMessage)
→ message delivered to the physical queue/topic

Key insight: Transport routing (step 3) and destination mapping (step 6) are configured in different places. Transport routing is in UseRouting(). Destination mapping is in the transport's DI registration (e.g., AddKafkaTransport()).

Where Does the Destination Come From?​

Each transport maps message types to physical destinations (queues, topics, entities) during DI registration. This is separate from the UseRouting() configuration.

Per-Transport Destination Mapping​

The snippets below show the mapping API for each transport in isolation. Serialization is a consumer concern -- the transport packages don't seat a default -- so a real composition also needs a services.AddPluggableSerialization(); call somewhere before the host starts (see the complete example below).

// Kafka: MapTopic maps message types to Kafka topics
services.AddKafkaTransport("kafka", kafka =>
{
kafka.BootstrapServers("broker1:9092")
.MapTopic<OrderCreatedEvent>("orders.events")
.MapTopic<PaymentProcessedEvent>("payments.events")
.WithTopicPrefix("prod-"); // Optional: prepends "prod-" to all topics
});

// RabbitMQ: MapExchange / MapQueue maps to exchanges or queues
services.AddRabbitMQTransport("rabbitmq", rmq =>
{
rmq.ConnectionString("amqps://localhost")
.MapExchange<OrderCreatedEvent>("orders-exchange")
.MapQueue<SendNotificationCommand>("notifications-queue")
.WithQueuePrefix("app-"); // Optional prefix
});

// Azure Service Bus: MapEntity maps to queues or topics
services.AddAzureServiceBusTransport("azure", asb =>
{
asb.ConnectionString(connectionString)
.MapEntity<OrderCreatedEvent>("orders-topic");
});

// AWS SQS: MapQueue maps to full queue URLs
services.AddAwsSqsTransport("sqs", sqs =>
{
sqs.UseRegion("us-east-1")
.MapQueue<OrderCreatedEvent>(
"https://sqs.us-east-1.amazonaws.com/123456789012/orders");
});

// Google Pub/Sub: MapTopic maps to Pub/Sub topic IDs
services.AddGooglePubSubTransport("pubsub", pubsub =>
{
pubsub.ProjectId("my-gcp-project")
.MapTopic<OrderCreatedEvent>("orders-topic");
});

How It All Connects​

Here is a complete example showing both layers configured together:

// Step 0: Serialization is a consumer concern -- the transport packages don't seat a default.
services.AddPluggableSerialization();

// Step 1: Register transports with destination mappings
services.AddKafkaTransport("kafka", kafka =>
{
kafka.BootstrapServers("broker:9092")
.MapTopic<OrderCreatedEvent>("orders.events");
});

services.AddRabbitMQTransport("rabbitmq", rmq =>
{
rmq.ConnectionString("amqps://localhost")
.MapQueue<SendNotificationCommand>("notifications");
});

// Step 2: Configure routing rules (which transport gets which message)
services.AddDispatch(dispatch =>
{
dispatch.UseRouting(routing =>
{
routing.Transport
.Route<OrderCreatedEvent>().To("kafka") // → kafka → "orders.events" topic
.Route<SendNotificationCommand>().To("rabbitmq") // → rabbitmq → "notifications" queue
.Default("rabbitmq");

routing.Endpoints
.Route<OrderCreatedEvent>()
.To("billing-service", "inventory-service");
});
});

For per-transport configuration details, see: Kafka | RabbitMQ | Azure Service Bus | AWS SQS | Google Pub/Sub

Quick Start​

services.AddDispatch(dispatch =>
{
dispatch.UseRouting(routing =>
{
// Tier 1: Transport selection - "which message bus?"
routing.Transport
.Route<OrderCreated>().To("rabbitmq")
.Route<PaymentProcessed>().To("kafka")
.Default("local");

// Tier 2: Endpoint routing - "which services receive this?"
routing.Endpoints
.Route<OrderCreated>()
.To("billing-service", "inventory-service")
.When(msg => msg.Amount > 1000).AlsoTo("fraud-detection");

// Cross-cutting
routing.Fallback.To("dead-letter-queue");
});
});

Transport Routing​

Transport routing selects the message bus for each message type. This is the first tier evaluated during routing.

IIntegrationEvent Constraint

Transport routing is restricted to IIntegrationEvent types only. This enforces the DDD principle that only integration events should cross service boundaries:

  • Commands are handled locally by a single handler (request-response semantics)
  • Domain events stay within aggregate boundaries
  • Integration events are designed for cross-service communication
// Compiles - OrderCreated implements IIntegrationEvent
routing.Transport.Route<OrderCreated>().To("rabbitmq");

// Does NOT compile - PlaceOrderCommand does not implement IIntegrationEvent
// routing.Transport.Route<PlaceOrderCommand>().To("rabbitmq");
// Error CS0311: constraint violation

Endpoint routing (routing.Endpoints) remains unrestricted and accepts any IDispatchMessage for local handler routing.

Basic Transport Selection​

routing.Transport
.Route<OrderCreated>().To("rabbitmq")
.Route<PaymentProcessed>().To("kafka")
.Route<InternalEvent>().To("local")
.Default("local"); // Fallback for unmatched types

Conditional Transport Selection​

Use .When() to add predicates for dynamic transport decisions:

routing.Transport
.Route<OrderCreated>()
.When(msg => msg.IsHighPriority).To("kafka") // Fast transport for priority orders
.Route<OrderCreated>().To("rabbitmq") // Standard transport otherwise
.Default("local");

Context-aware predicates have access to IMessageContext:

routing.Transport
.Route<OrderCreated>()
.When((msg, ctx) => ctx.Items.ContainsKey("express")).To("kafka");
Conditional rules are re-evaluated for every message

Rules are evaluated in declaration order, and the first match wins. When a message type has any applicable predicate, the decision depends on the individual message, so it is made fresh on every dispatch — the selector never reuses an earlier message's outcome for a later one.

In the example above that is what makes the two rules behave as written: an ordinary OrderCreated takes the rabbitmq fallback, and a high-priority one that arrives afterwards still reaches kafka. Ordering of traffic does not change the routing anyone gets.

A message type with no applicable predicate is routed purely by its type, so that decision is cached. You get the cheap path exactly where it is sound, and per-message evaluation exactly where it is required.

ITransportSelector Interface​

public interface ITransportSelector
{
ValueTask<string> SelectTransportAsync(
IDispatchMessage message,
IMessageContext context,
CancellationToken cancellationToken);

IEnumerable<string> GetAvailableTransports(Type messageType);
}

Endpoint Routing​

Endpoint routing determines which services receive a message. This is the second tier, supporting multicast delivery.

Basic Endpoint Routing​

routing.Endpoints
.Route<OrderCreated>()
.To("billing-service", "inventory-service");

Conditional Endpoint Routing​

Add endpoints conditionally based on message content:

routing.Endpoints
.Route<OrderCreated>()
.To("billing-service", "inventory-service")
.When(msg => msg.Amount > 1000).AlsoTo("fraud-detection")
.When(msg => msg.IsInternational).AlsoTo("customs-service");

Multiple Message Types​

Chain routing rules for different message types:

routing.Endpoints
.Route<OrderCreated>()
.To("billing-service", "inventory-service")
.Route<PaymentProcessed>()
.To("notification-service");

IEndpointRouter Interface​

public interface IEndpointRouter
{
ValueTask<IReadOnlyList<string>> RouteToEndpointsAsync(
IDispatchMessage message,
IMessageContext context,
CancellationToken cancellationToken);

bool CanRouteToEndpoint(IDispatchMessage message, string endpoint);

IEnumerable<RouteInfo> GetEndpointRoutes(
IDispatchMessage message,
IMessageContext context);
}

Unified Router​

IDispatchRouter combines both tiers into a single routing call. The RoutingMiddleware uses this interface internally.

public interface IDispatchRouter
{
ValueTask<RoutingDecision> RouteAsync(
IDispatchMessage message,
IMessageContext context,
CancellationToken cancellationToken);

bool CanRouteTo(IDispatchMessage message, string destination);

IEnumerable<RouteInfo> GetAvailableRoutes(
IDispatchMessage message,
IMessageContext context);
}

RoutingDecision​

The RoutingDecision record encapsulates both transport and endpoint decisions:

public sealed record RoutingDecision
{
public required string Transport { get; init; }
public required IReadOnlyList<string> Endpoints { get; init; }
public bool IsSuccess => !string.IsNullOrEmpty(Transport);
public string? FailureReason { get; init; }
public IReadOnlyList<string> MatchedRules { get; init; } = [];

public static RoutingDecision Success(
string transport,
IReadOnlyList<string> endpoints,
IReadOnlyList<string>? matchedRules = null);

public static RoutingDecision Failure(string reason);
}

After routing, the decision is stored on IMessageContext.RoutingDecision and accessible in subsequent middleware and handlers.

Fallback Configuration​

Configure a fallback endpoint for messages that don't match any routing rules:

routing.Fallback
.To("dead-letter-queue")
.WithReason("No matching routing rules");

When no endpoint rules match and a fallback is configured, the fallback endpoint is included in the routing decision. If routing fails entirely (no transport selected), RoutingMiddleware returns a 404 failure result.

Error and Fallback Behavior​

Understanding what happens when routing fails is critical for building reliable systems.

When No Transport Matches​

If ITransportSelector cannot match any rule (and no .Default() is configured), DefaultDispatchRouter returns:

RoutingDecision.Failure("No transport could be selected for the message")

RoutingMiddleware then returns a 404 failure result with ProblemDetailsTypes.Routing:

// The result your code receives:
var result = await dispatcher.DispatchAsync(message, ct);
// result.Succeeded == false
// result.ProblemDetails.Status == 404
// result.ProblemDetails.Detail == "Routing failed: No transport could be selected for the message"

When a Transport Name Isn't Registered​

If routing selects "kafka" but no AddKafkaTransport("kafka", ...) was called, the transport registry throws:

InvalidOperationException: Cannot set default transport to 'kafka': transport is not registered.
Available transports: rabbitmq, local

This typically surfaces at startup (not at runtime) when the transport registry validates its configuration.

Prevention Checklist​

  1. Always set .Default() on routing.Transport to catch unmatched message types
  2. Ensure every transport name used in Route<T>().To("name") has a matching AddXxxTransport("name", ...) registration
  3. Configure routing.Fallback to catch messages with no matching endpoint rules
  4. Enable logging — RoutingMiddleware emits event ID 31604 at Warning level

RouteInfo​

The RouteInfo class provides diagnostic information about available routes:

public class RouteInfo
{
public string Name { get; }
public string Endpoint { get; }
public int Priority { get; }
public string? BusName { get; set; }
public Dictionary<string, object?> Metadata { get; init; }
}

Lower priority values indicate higher precedence (evaluated first), following the ASP.NET Core routing convention.

Diagnostics​

Discovering Available Routes​

var router = serviceProvider.GetRequiredService<IDispatchRouter>();
var routes = router.GetAvailableRoutes(message, context);

foreach (var route in routes)
{
Console.WriteLine($"Route: {route.Name} -> {route.Endpoint} (Priority: {route.Priority})");
}

Pre-Flight Validation​

if (router.CanRouteTo(message, "billing-service"))
{
// Endpoint is configured for this message type
}

Middleware Logging​

RoutingMiddleware emits structured logs:

Event IDLevelMessage
31605Information"Message routed to: {Target}"
31628Debug"Routing completed: transport={Transport}, endpoints={EndpointCount}"
31604Warning"Routing failed: {Reason}"

Observability​

Enable OpenTelemetry tracing and metrics for routing decisions:

services.AddDispatch(dispatch =>
{
dispatch.UseOpenTelemetry(); // Enables both tracing and metrics
dispatch.UseRouting(routing => { /* ... */ });
});

DI Registration​

UseRouting() registers three services as singletons:

ServiceImplementation
ITransportSelectorConfiguredTransportSelector
IEndpointRouterConfiguredEndpointRouter
IDispatchRouterDefaultDispatchRouter

All implementations are internal. Consumers interact through the public abstractions only.

Best Practices​

Separate the Three Layers​

Keep transport, destination, and endpoint concerns distinct:

// Good: Clear separation
routing.Transport.Route<OrderCreated>().To("rabbitmq");
routing.Endpoints.Route<OrderCreated>().To("billing-service");

// Avoid: Mixing concerns in predicates

Set a Default Transport​

Always configure a default transport for unmatched message types:

routing.Transport.Default("local");

Use Composable Predicates​

Express routing logic through .When() predicates instead of complex configuration:

// Content-based routing
.When(msg => msg.Amount > 1000)

// Header/context-based routing
.When((msg, ctx) => ctx.Items["tenant"] is "premium")

// Geographic routing
.When(msg => msg.Region == "EU")

// Time-based routing
.When(msg => DateTime.UtcNow.Hour is >= 9 and < 17)

Configure Fallback Routing​

Ensure messages always have a destination:

routing.Fallback.To("dead-letter-queue");

Source Code Reference​

ComponentLocation
IDispatchRoutersrc/Dispatch/Excalibur.Dispatch.Abstractions/Routing/IDispatchRouter.cs
ITransportSelectorsrc/Dispatch/Excalibur.Dispatch.Abstractions/Routing/ITransportSelector.cs
IEndpointRoutersrc/Dispatch/Excalibur.Dispatch.Abstractions/Routing/IEndpointRouter.cs
RoutingDecisionsrc/Dispatch/Excalibur.Dispatch.Abstractions/Routing/RoutingDecision.cs
RouteInfosrc/Dispatch/Excalibur.Dispatch.Abstractions/Routing/RouteInfo.cs
RoutingMiddlewaresrc/Dispatch/Excalibur.Dispatch/Routing/RoutingMiddleware.cs
TransportRegistrysrc/Dispatch/Excalibur.Dispatch/Transport/TransportRegistry.cs
Builder interfacessrc/Dispatch/Excalibur.Dispatch/Routing/Builder/

See Also​