# Routing and Endpoints - Mocha

> Understand how Mocha discovers endpoints, builds topology, and routes messages using naming conventions - and how to override any of it.

Canonical source: https://chillicream.com/docs/mocha/routing-and-endpoints

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.

## The endpoint model

When you register handlers and pick a transport, Mocha wires up both sides of the messaging connection automatically:

C#

```
builder.Services
    .AddMessageBus()
    .AddEventHandler<OrderPlacedHandler>()
    .AddRequestHandler<GetOrderStatusHandler>()
    .AddRabbitMQ();
```

That registration produces:

- A **receive endpoint** named `my-service.order-placed` (subscribe route, bound to `OrderPlacedHandler`)
- A **receive endpoint** named `get-order-status` (request route, bound to `GetOrderStatusHandler`)
- A **dispatch endpoint** for publishing `OrderPlacedEvent`
- A **dispatch endpoint** for sending `GetOrderStatusRequest`
- A **reply receive endpoint** for inbound responses
- A **reply dispatch endpoint** for outbound responses
- **Error endpoints** (`_error` suffix) for each receive endpoint

All derived from your handler types and message types through naming conventions.

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.

## How routing works

Mocha maintains two kinds of routes that work together to move messages between services.

### Inbound routes

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)              |

### Outbound routes

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:

1. If an explicit route is registered with `AddMessage<T>()`, use it.
2. Otherwise, derive the endpoint name from naming conventions.

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.

### Startup-time topology

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](https://www.enterpriseintegrationpatterns.com/patterns/messaging/MessageEndpoint.html) pattern: an endpoint bridges your application code to the messaging infrastructure, and that bridge is established at initialization time.

## Naming conventions

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.

### Set the service name

For subscribe (pub/sub) endpoints, the naming convention prefixes the endpoint name with the service name:

C#

```
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](https://www.enterpriseintegrationpatterns.com/patterns/messaging/PointToPointChannel.html) for the full explanation of why fan-out and point-to-point channels work this way.

### Receive endpoint naming

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.

### Publish endpoint naming

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 |

### Special endpoint names

| 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.

## Customize outbound routes

To override where a message is sent or published, use `AddMessage<T>()` with a route configuration:

C#

```
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:

C#

```
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`:

C#

```
await bus.SendAsync(new ReserveInventoryCommand
{
    OrderId = orderId,
    ProductId = productId,
    Quantity = 3
},
new SendOptions
{
    Endpoint = new Uri("rabbitmq://custom-inventory-queue")
},
cancellationToken);
```

## Customize queues and binding

Use `transport.Queue("name")` as the primary API when you need to customize receive topology. The queue builder starts with the queue as the unit of configuration. It can declare the queue, bind handlers or consumers, opt into convention bindings, and configure receive settings in one place.

Calling `Queue("name")` by itself creates an infrastructure queue. Adding `Handler<T>()`, `Consumer<T>()`, or `Receives<T>()` materializes a receive endpoint for that queue.

C#

```
builder.Services
    .AddMessageBus()
    .AddEventHandler<OrderPlacedHandler>()
    .AddEventHandler<PaymentReceivedHandler>()
    .AddRabbitMQ(transport =>
    {
        transport.BindExplicitly();

        transport.Queue("combined-orders")
            .BindImplicitly()
            .MaxConcurrency(5)
            .FaultEndpoint("order-errors")
            .SkippedEndpoint("order-skipped")
            .Handler<OrderPlacedHandler>()
            .Handler<PaymentReceivedHandler>();
    });
```

Both handlers now consume from the same `combined-orders` queue. Without explicit transport binding, they would each get their own convention-derived endpoint.

### Bind handlers or message types

Use `.Handler<T>()` or `.Consumer<T>()` when you know the concrete handler or consumer types that should run on the queue:

C#

```
transport.Queue("combined-orders")
    .BindImplicitly()
    .Handler<OrderPlacedHandler>()
    .Handler<PaymentReceivedHandler>();

transport.Queue("audit-orders")
    .BindImplicitly()
    .Consumer<OrderAuditConsumer>();
```

Use `.Receives<T>()` when the queue should receive a message type and Mocha should connect all registered handlers for that message type:

C#

```
builder.Services
    .AddMessageBus()
    .AddEventHandler<OrderPlacedHandler>()
    .AddEventHandler<OrderAuditHandler>()
    .AddRabbitMQ(transport =>
    {
        transport.BindExplicitly();

        transport.Queue("all-orders")
            .BindImplicitly()
            .Receives<OrderPlaced>();
    });
```

Both `OrderPlacedHandler` and `OrderAuditHandler` now receive from the `all-orders` queue. This is topology-first design: you declare what messages a queue handles, and Mocha wires up all registered handlers.

Use `.Handler<T>()` when you know which handler types to bind. Use `.Receives<T>()` when you care about the message type and want all handlers for that type automatically connected. The same queue can use both approaches:

C#

```
transport.Queue("orders")
    .BindImplicitly()
    .Receives<OrderPlaced>()
    .Handler<OrderAuditHandler>();
```

If a message type is declared with `.Receives<T>()` but no handler is registered, Mocha throws an exception at startup.

### Understand implicit and explicit binding

Binding has two scopes: transport and queue.

At the transport scope, `BindImplicitly()` is the default. The transport auto-discovers registered handlers, creates convention-named queues, and adds the convention-derived exchange, topic, or subscription bindings for the messages each handler consumes.

C#

```
builder.Services
    .AddMessageBus()
    .AddEventHandler<OrderPlacedHandler>()      // -> my-service.order-placed
    .AddEventHandler<PaymentReceivedHandler>()  // -> my-service.payment-received
    .AddRabbitMQ(transport =>
    {
        transport.BindImplicitly(); // default
    });
```

`BindExplicitly()` at the transport scope turns off that auto-discovery. Use it when the queues you define with `Queue("name")` should be the complete receive topology for that transport.

At the queue scope, `BindImplicitly()` keeps convention-derived source bindings for the message types handled by that queue. This is the common combination for custom queue names:

C#

```
transport.BindExplicitly();

transport.Queue("order-processing")
    .BindImplicitly()
    .Receives<OrderPlaced>();
```

`BindExplicitly()` at the queue scope suppresses convention-derived source bindings for that queue. Use it when you bind the queue to a specific source yourself, for example with `BindFrom(...)` or a transport-specific topology declaration.

C#

```
transport.BindExplicitly();

transport.Queue("regional-orders")
    .BindExplicitly()
    .BindFrom(new Uri("exchange:region-events"), "eu.*")
    .Handler<OrderPlacedHandler>();
```

### Configure convention endpoints

Use `transport.Handler<T>()` or `transport.Consumer<T>()` at the end of the transport configuration when you want to keep the convention-derived queue name and only tune the endpoint for one handler or consumer.

This is useful for small endpoint changes, but `Queue("name")` is the preferred surface when the queue name, queue topology, or multiple handler bindings are part of the customization.

C#

```
builder.Services
    .AddMessageBus()
    .AddEventHandler<OrderPlacedHandler>()
    .AddRabbitMQ(rabbit =>
    {
        rabbit.Handler<OrderPlacedHandler>()
            .ConfigureEndpoint(ep => ep
                .MaxConcurrency(5)
                .FaultEndpoint("order-errors")
                .SkippedEndpoint("order-skipped"));
    });
```

The same pattern works for consumers:

C#

```
rabbit.Consumer<OrderAuditConsumer>()
    .ConfigureEndpoint(ep => ep.MaxConcurrency(3));
```

Inside `ConfigureEndpoint()`, you have access to transport-specific settings. To set prefetch on a RabbitMQ endpoint:

C#

```
builder.Services
    .AddMessageBus()
    .AddEventHandler<OrderPlacedHandler>()
    .AddRabbitMQ(rabbit =>
    {
        rabbit.Handler<OrderPlacedHandler>()
            .ConfigureEndpoint(ep => ep.MaxPrefetch(50));
    });
```

For PostgreSQL, configure the batch size:

C#

```
builder.Services
    .AddMessageBus()
    .AddEventHandler<OrderPlacedHandler>()
    .AddPostgres(transport =>
    {
        transport.Handler<OrderPlacedHandler>()
            .ConfigureEndpoint(ep => ep.MaxBatchSize(100));
    });
```

See the [RabbitMQ](https://chillicream.com/docs/mocha/transports/rabbitmq), [PostgreSQL](https://chillicream.com/docs/mocha/transports/postgres), and [InMemory](https://chillicream.com/docs/mocha/transports/in-memory) transport pages for the full set of transport-specific queue and endpoint settings.

### Multi-transport handler routing

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:

C#

```
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 `BindExplicitly()` and keeps the configuration minimal.

For outbound endpoints, use `DispatchEndpoint("name")`:

C#

```
builder.Services
    .AddMessageBus()
    .AddRabbitMQ(transport =>
    {
        transport.DispatchEndpoint("custom-dispatch")
            .Publish<OrderPlacedEvent>()
            .Send<ProcessPaymentCommand>();
    });
```

## Scope precedence

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:

C#

```
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.

## Next steps

Your routing and endpoint configuration is set. From here:

- [**Middleware and Pipelines**](https://chillicream.com/docs/mocha/middleware-and-pipelines) \- Write custom middleware, control pipeline ordering, and understand how the three pipeline stages interact. Want to customize the processing pipeline? That's the next page.
- [**Reliability**](https://chillicream.com/docs/mocha/reliability) \- Configure fault handling, circuit breakers, concurrency limits, the transactional outbox, and the idempotent inbox.
- [**Transports**](https://chillicream.com/docs/mocha/transports) \- Dive into transport-specific configuration for RabbitMQ and InMemory.
[Edit this page on GitHub](https://github.com/ChilliCream/graphql-platform/edit/main/website/content/docs/mocha/routing-and-endpoints.md)

Last updated on **June 30, 2026** by **Tobias Tengler**
