website/src/docs/mocha/v16/routing-and-endpoints.md
An endpoint is the combination of a transport address (a queue or exchange) and a pipeline that processes messages. Mocha distinguishes between receive endpoints (which consume) and dispatch endpoints (which produce). Every handler you register becomes a receive endpoint; every message you publish or send is dispatched through a dispatch endpoint. By default, Mocha creates endpoints automatically from your handler and message types using naming conventions. Most applications never touch routing configuration directly - but you can configure the topology yourself completely when the defaults don't fit.
This page explains what those conventions do, how to verify they produce the topology you expect, and how to override them when the defaults don't fit.
When you register handlers and pick a transport, Mocha wires up both sides of the messaging connection automatically:
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>()
.AddRequestHandler<GetOrderStatusHandler>()
.AddRabbitMQ();
That registration produces:
my-service.order-placed (subscribe route, bound to OrderPlacedHandler)get-order-status (request route, bound to GetOrderStatusHandler)OrderPlacedEventGetOrderStatusRequest_error suffix) for each receive endpointAll derived from your handler types and message types through naming conventions.
flowchart
subgraph Registration
H1[OrderPlacedHandler]
H2[GetOrderStatusHandler]
end
subgraph ReceiveEndpoints["Receive Endpoints"]
RE1["my-service.order-placed"]
RE2["get-order-status"]
RE3["reply-..."]
end
subgraph DispatchEndpoints["Dispatch Endpoints"]
DE1["order-placed (exchange)"]
DE2["get-order-status (queue)"]
DE3["replies (queue)"]
end
H1 --> RE1
H2 --> RE2
RE1 <-->|broker| DE1
RE2 <-->|broker| DE2
RE3 <-->|broker| DE3
This is the default behavior: you declare what you handle, and the framework derives the endpoints and routes from those declarations. You can configure everything manually when you need to override a convention.
Mocha maintains two kinds of routes that work together to move messages between services.
An inbound route connects a message type to a receive endpoint. When you register a handler with .AddEventHandler<T>() or .AddRequestHandler<T>(), Mocha creates an inbound route that tells the transport which messages to deliver to which consumer.
| Handler interface | Route kind | Endpoint type |
|---|---|---|
IEventHandler<T>, IBatchEventHandler<T> | Subscribe | Queue bound to an exchange or topic (fan-out) |
IEventRequestHandler<TRequest> | Send | Dedicated queue (point-to-point) |
IEventRequestHandler<TRequest, TResponse> | Request | Dedicated queue (point-to-point) |
An outbound route connects a message type to a dispatch endpoint. When you call bus.PublishAsync<T>() or bus.SendAsync(), Mocha looks up the outbound route for the message type and dispatches through the corresponding endpoint.
Routing priority: Mocha resolves outbound routes in this order:
AddMessage<T>(), use it.When a message goes somewhere unexpected, open the topology visualizer first - it shows you the complete routing picture at a glance. If you need to dig deeper, check for an explicit AddMessage<T>() registration, then check what the conventions produce.
Mocha resolves all endpoints and builds broker topology when the bus starts, not when the first message is sent. If your exchange or queue configuration is invalid - a mis-spelled exchange name, an incompatible binding - you will know at startup. Topology errors surface immediately, not silently on the first SendAsync call.
This design is reflected in the Message Endpoint pattern: an endpoint bridges your application code to the messaging infrastructure, and that bridge is established at initialization time.
Mocha derives endpoint names automatically from your handler and message types. PascalCase type names become kebab-case; common suffixes (Handler, Consumer, Command, Event, Message, Query, Response) are stripped.
For subscribe (pub/sub) endpoints, the naming convention prefixes the endpoint name with the service name:
builder.Services
.AddMessageBus()
.Host(h => h.ServiceName("order-service"))
.AddEventHandler<OrderPlacedHandler>()
.AddRabbitMQ();
The receive endpoint is named order-service.order-placed. Without the .Host() call, the service name defaults to the SERVICE_NAME or OTEL_SERVICE_NAME environment variable, or falls back to the entry assembly name.
Why the service prefix?
Events use fan-out delivery: a single published message is delivered to every subscribing service. For fan-out to work correctly with point-to-point queues, each subscribing service needs its own queue. Without a service-specific prefix, two services consuming the same event would share a single queue and compete for messages - each service would only process half the events.
The service prefix is what makes each service's queue unique. See Point-to-Point Channel for the full explanation of why fan-out and point-to-point channels work this way.
For subscribe routes (event handlers), the endpoint name combines the service name with the handler name:
| Handler type | Service name | Endpoint name |
|---|---|---|
OrderPlacedEventHandler | catalog | catalog.order-placed-event |
BillingHandler | billing | billing.billing |
OrderAuditConsumer | audit | audit.order-audit |
The Handler and Consumer suffixes are stripped. The service name prefix ensures each service gets its own queue for the same event type.
For send and request routes, the endpoint name comes from the message type directly, without a service prefix:
| Message type | Endpoint name |
|---|---|
ReserveInventoryCommand | reserve-inventory |
ProcessRefundCommand | process-refund |
GetProductRequest | get-product |
Send endpoints are shared across services: any service sending ReserveInventoryCommand dispatches to the same reserve-inventory queue. There is only one destination for a command - that's the point-to-point guarantee.
For publish (fan-out) endpoints, the name includes the message namespace in kebab-case:
| Message type | Namespace | Endpoint name |
|---|---|---|
OrderPlacedEvent | Demo.Contracts.Events | demo.contracts.events.order-placed |
PaymentCompletedEvent | Demo.Contracts.Events | demo.contracts.events.payment-completed |
| Purpose | Name pattern | Example |
|---|---|---|
| Error queue | {endpoint}_error | catalog.order-placed-event_error |
| Skipped queue | {endpoint}_skipped | catalog.order-placed-event_skipped |
| Reply queue | response-{guid:N} | response-3f2504e04f8911d39a0c0305e82c3301 |
Error queues receive messages that failed processing. Skipped queues receive messages that no consumer could handle. Reply queues are temporary, per-instance queues used for request/reply correlation.
To override where a message is sent or published, use AddMessage<T>() with a route configuration:
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>()
.AddMessage<OrderPlacedEvent>(m =>
{
m.Publish(r => r.ToExchange("custom-orders-exchange"));
})
.AddRabbitMQ();
OrderPlacedEvent now publishes to custom-orders-exchange instead of the convention-derived name. This is an explicit route - it takes priority over naming conventions. The receive endpoint is unaffected; it still subscribes based on the handler's message type.
For send (point-to-point) routes:
builder.Services
.AddMessageBus()
.AddMessage<ProcessPaymentCommand>(m =>
{
m.Send(r => r.ToQueue("payment-processing-queue"));
})
.AddRabbitMQ();
Use these extension methods to target specific destination types when configuring outbound routes:
| Method | URI Scheme | Example |
|---|---|---|
ToQueue(name) | queue: | r.ToQueue("payment-queue") |
ToExchange(name) | exchange: | r.ToExchange("events-exchange") |
ToTopic(name) | topic: | r.ToTopic("orders.placed") |
The URI schemes (queue:, exchange:, topic:) tell Mocha what kind of transport entity to target. queue: addresses a point-to-point queue directly. exchange: addresses a fan-out exchange (RabbitMQ) or equivalent. topic: addresses a topic-based routing entity. The transport interprets these schemes and maps them to its native concepts.
To bypass routing entirely and send to a specific address at call time, pass a SendOptions:
await bus.SendAsync(new ReserveInventoryCommand
{
OrderId = orderId,
ProductId = productId,
Quantity = 3
},
new SendOptions
{
Endpoint = new Uri("rabbitmq://custom-inventory-queue")
},
cancellationToken);
By default, the transport uses implicit binding. Every registered handler is automatically bound to a receive endpoint named by convention:
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>() // -> receive endpoint: my-service.order-placed
.AddEventHandler<PaymentReceivedHandler>() // -> receive endpoint: my-service.payment-received
.AddRabbitMQ();
When you want convention-based endpoint naming but need to customize specific handler endpoints, use transport.Handler<T>(). This claims the handler for the transport and gives you access to the endpoint descriptor through ConfigureEndpoint():
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>()
.AddEventHandler<PaymentReceivedHandler>()
.AddRabbitMQ(transport =>
{
// Claim OrderPlacedHandler and configure its convention-named endpoint
transport.Handler<OrderPlacedHandler>()
.ConfigureEndpoint(ep => ep.MaxConcurrency(10))
.ConfigureEndpoint(ep => ep.FaultEndpoint("order-errors"));
});
PaymentReceivedHandler still gets an auto-discovered endpoint with default settings. OrderPlacedHandler gets the same convention-derived endpoint name, but with concurrency capped at 10 and a custom fault endpoint.
Multiple ConfigureEndpoint() calls on the same handler compose in declaration order.
The same pattern works for consumers:
transport.Consumer<OrderAuditConsumer>()
.ConfigureEndpoint(ep => ep.MaxConcurrency(3));
Inside ConfigureEndpoint(), you have access to transport-specific settings. To set prefetch on a RabbitMQ endpoint:
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>()
.AddRabbitMQ(transport =>
{
transport.Handler<OrderPlacedHandler>()
.ConfigureEndpoint(ep => ep.MaxPrefetch(50));
});
For PostgreSQL, configure the batch size:
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>()
.AddPostgres(transport =>
{
transport.Handler<OrderPlacedHandler>()
.ConfigureEndpoint(ep => ep.MaxBatchSize(100));
});
This approach sits between implicit and explicit binding: you keep automatic endpoint naming but gain per-handler configuration. See the RabbitMQ, PostgreSQL, and InMemory transport pages for the full set of transport-specific endpoint settings.
Switch to explicit binding when you need full control over which handlers run on which endpoints. With explicit binding, the transport does not auto-discover endpoints - you must declare each one:
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>()
.AddEventHandler<PaymentReceivedHandler>()
.AddRabbitMQ(transport =>
{
transport.BindHandlersExplicitly();
// Bind both handlers to the same endpoint
transport.Endpoint("combined-orders")
.Handler<OrderPlacedHandler>()
.Handler<PaymentReceivedHandler>();
});
Both handlers now consume from the same combined-orders queue. Without explicit binding, they would each get their own endpoint.
You can configure per-endpoint settings in two ways: through a named endpoint, or through transport.Handler<T>() which derives the endpoint name from conventions.
To configure a named endpoint directly:
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>()
.AddRabbitMQ(rabbit =>
{
rabbit.Endpoint("order-processing")
.Handler<OrderPlacedHandler>()
.MaxConcurrency(5)
.FaultEndpoint("order-errors")
.SkippedEndpoint("order-skipped");
});
To configure through a handler claim, which derives the endpoint name from conventions:
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>()
.AddRabbitMQ(rabbit =>
{
rabbit.Handler<OrderPlacedHandler>()
.ConfigureEndpoint(ep => ep
.MaxConcurrency(5)
.FaultEndpoint("order-errors")
.SkippedEndpoint("order-skipped"));
});
Use transport.Endpoint("name") when you need to control the endpoint name or bind multiple handlers to the same endpoint. Use transport.Handler<T>() when you want the convention-derived name and are configuring a single handler's endpoint.
In a multi-transport setup, Handler<T>() also determines which transport owns the handler. Mark one transport as the default with .IsDefaultTransport(), then claim specific handlers on other transports:
builder.Services
.AddMessageBus()
.AddEventHandler<OrderPlacedHandler>()
.AddEventHandler<AuditHandler>()
.AddRabbitMQ(r => r.IsDefaultTransport()) // default for unclaimed handlers
.AddInMemory(m => m.Handler<AuditHandler>()); // AuditHandler claimed by InMemory
// OrderPlacedHandler → RabbitMQ (default, implicit)
// AuditHandler → InMemory (claimed)
A claimed handler is bound to the claiming transport regardless of which transport is the default. Unclaimed handlers fall through to the default transport. This is the recommended pattern for multi-transport routing - it avoids BindHandlersExplicitly() and keeps the configuration minimal.
For outbound endpoints:
builder.Services
.AddMessageBus()
.AddRabbitMQ(transport =>
{
transport.DispatchEndpoint("custom-dispatch")
.Publish<OrderPlacedEvent>()
.Send<ProcessPaymentCommand>();
});
Configuration in Mocha follows a three-level scope hierarchy: bus > transport > endpoint. The most specific scope wins.
Bus (global defaults)
-> Transport (transport-specific overrides)
-> Endpoint (per-endpoint overrides)
This applies to middleware pipelines, circuit breakers, concurrency limiters, and any feature that can be configured at multiple levels:
builder.Services
.AddMessageBus()
.AddConcurrencyLimiter(opts => opts.MaxConcurrency = 20) // bus-level default
.AddRabbitMQ(transport =>
{
transport.AddConcurrencyLimiter(opts => opts.MaxConcurrency = 10); // transport override
transport.Endpoint("high-throughput")
.MaxConcurrency(50); // endpoint override
});
The high-throughput endpoint processes 50 messages concurrently. All other RabbitMQ endpoints use 10. Endpoints on other transports use the bus default of 20.
The middleware pipeline is compiled per-endpoint from the same three layers: bus middleware runs first, then transport middleware, then endpoint middleware. This means a retry policy registered at the bus level applies everywhere, but you can add an extra circuit breaker only for a specific endpoint. Other pages in this documentation reference this scope hierarchy as the canonical model - it governs middleware, reliability features, and observability configuration uniformly.
Your routing and endpoint configuration is set. From here: