Skip to content
Kumar Chandrachooda
.NET

A Message Broker Behind an Interface

Convey's messaging abstraction is two interfaces and an attribute - and the naming conventions underneath them quietly turn your C# class names into a public wire contract.

By Kumar Chandrachooda 23 Mar 2026 5 min read
One publish verb, many routes

A few services into a RabbitMQ estate, someone renames a C# class. OrderApproved becomes OrderAccepted — a pure refactor, the reviewer agrees, no behaviour changed. The deploy goes out and a downstream service quietly stops receiving events, because in a convention-based messaging stack the class name was the routing key, and nobody had ever written that down.

This part of the series is about the layer of Convey — the open-source .NET microservices toolkit from DevMentors — where that lesson lives: Convey.MessageBrokers, the broker abstraction package, and the conventions machinery that decides what your messages are called on the wire. It is the smallest package in the toolkit and it punches far above its weight, because it defines the contract every messaging article after this one builds on.

Two interfaces, one verb

The entire broker-facing API surface is two interfaces. Here is the publisher, verbatim — this is not an excerpt, it is the whole file body:

// Convey.MessageBrokers/IBusPublisher.cs
public interface IBusPublisher
{
    Task PublishAsync<T>(T message, string messageId = null, string correlationId = null,
        string spanContext = null, object messageContext = null,
        IDictionary<string, object> headers = null) where T : class;
}

And the subscriber:

// Convey.MessageBrokers/IBusSubscriber.cs
public interface IBusSubscriber : IDisposable
{
    IBusSubscriber Subscribe<T>(Func<IServiceProvider, T, object, Task> handle) where T : class;
}

Notice what is not here. There is no SendAsync versus PublishAsync distinction — commands and events travel through the same verb, and the CQRS semantics are layered on top by a separate package (part 11). There is no topology API: you cannot declare an exchange or bind a queue through the abstraction. There is no transaction, no batch, no acknowledgement handle. The interface commits you to almost nothing, which is exactly why every Convey service can depend on it while only the composition root knows RabbitMQ exists.

The Subscribe signature is worth a second look. The handler receives an IServiceProvider — the subscription is registered once at startup, and the implementation is expected to create a fresh scope per delivery. And the third parameter, the untyped object, is the correlation context: the caller's ambient metadata, deserialized from a message header. It being object is a deliberate trade — Convey does not want to dictate your context shape — but it means every consumer casts, and the compiler checks nothing.

The conventions are the real contract

The interesting engineering is not the interfaces; it is how a T becomes an exchange, a routing key and a queue. Convey resolves that through a conventions pipeline — registry first, then a builder that derives names from the type:

// Convey.MessageBrokers.RabbitMQ/Conventions/ConventionsProvider.cs
conventions = _registry.Get(type) ?? new MessageConventions(type,
    _builder.GetRoutingKey(type),
    _builder.GetExchange(type),
    _builder.GetQueue(type));

The builder's rules, condensed from ConventionsBuilder.cs:

  • Routing key — the class name. OrderCreated publishes with routing key order_created when conventionsCasing is snakeCase.
  • Exchange — the configured exchange name, falling back to the message type's assembly name.
  • Queue — a template, by default {{assembly}}/{{exchange}}.{{message}}, so the Deliveries service consuming OrderCreated from the orders exchange gets a queue like conveyor.services.deliveries/orders.order_created.

When a message belongs to another service — the normal case for a subscriber — you override the defaults with the attribute:

[Message("orders")]
public class OrderCreated : IEvent
{
    public Guid OrderId { get; }
    public OrderCreated(Guid orderId) => OrderId = orderId;
}

That is the Deliveries sample's local copy of the Orders service's event: same class name, same shape, [Message] pointing the exchange at orders. The attribute can also pin the routing key and queue explicitly, and options flags (conventions:messageAttribute:ignoreExchange and friends) can switch the attribute off wholesale — useful when one side of an integration cannot rename its types.

Two consequences follow, and they are the thesis of this article.

First: your class names are your public API. The rename that opened this post is a breaking wire change, invisible to the compiler, invisible to tests that use an in-memory bus. The mitigation is discipline: treat message contract classes like database schema — pin names with [Message] once a contract ships, and copy contracts into consumers (as the samples do) rather than sharing an assembly, so each side controls its own coupling.

Second: duplicated simple names collide. Conventions are derived from type.Name, not the full name. Two messages called StatusChanged in different namespaces produce identical routing keys, and if they meet on one exchange your consumers will happily deserialize the wrong payload into the right-shaped class. Convey will not warn you; naming hygiene is on you.

One smaller wart while we are being honest: MessageAttribute has an External flag, and as far as I can find nothing in the toolkit reads it. Dead parameters in a public attribute are the archaeology of open source — you can see where an idea was headed.

The context that rides along

The second half of the abstractions package is ambient metadata. PublishAsync takes a messageContext; the RabbitMQ implementation serializes it into a configurable header (message_context by default) and the consuming side deserializes it back and exposes it two ways: as the object handed to your handler, and through an ICorrelationContextAccessor backed by an AsyncLocal — the same pattern as ASP.NET Core's IHttpContextAccessor:

// Convey.MessageBrokers/CorrelationContextAccessor.cs
public class CorrelationContextAccessor : ICorrelationContextAccessor
{
    private static readonly AsyncLocal<CorrelationContextHolder> Holder = new();

    public object CorrelationContext
    {
        get => Holder.Value?.Context;
        ...
    }
}

There is a matching IMessagePropertiesAccessor for the broker-level properties (message id, correlation id, timestamp, raw headers). Between them, anything on the consuming code path — a logging decorator, a repository, an outbox — can see which message it is running under without threading parameters through every method. It is service-location-flavoured, and I have mixed feelings about it in domain code, but for infrastructure decorators it is exactly right, and part 19's correlation logging leans on it hard.

Thin on purpose — and where the layer leaks

I want to be fair about what this abstraction is and is not. It is not a portability layer in the “swap RabbitMQ for Kafka on Friday” sense. The conventions interfaces (IConventions, IConventionsBuilder, IConventionsProvider) physically live in the RabbitMQ package, not the abstractions package; exchanges and routing keys are AMQP concepts; a Kafka implementation would need different vocabulary. In practice the layering buys you three real things:

  1. Testability. Application code depends on IBusPublisher; an in-memory fake is ten lines.
  2. A single choke point. Outbox (part 12) decorates IBusPublisher. Tracing (part 21) plugs into the consume pipeline. Neither touches application code.
  3. Uniform semantics. Every message in the estate carries a message id, a correlation id and a context header, because the only door is one method that handles all three.

That is a modest, honest contract — and modest contracts are the ones that survive. The next two parts go below the waterline: how the RabbitMQ implementation actually moves these messages (part 9), and everything that happens when a handler throws (part 10). That is where the comfortable defaults you just saw start earning — and occasionally costing — real money.