Your Routing Key Is a Class Name
On Inflow's microservices branch the wire address of every event is derived from the CLR type name, computed independently on both sides of the network. Two records in two assemblies match only because someone spelled them the same way.
There is a category of change that your IDE will tell you is safe and your production system will tell you is not. Renaming a class is the classic. Right-click, rename, every reference updated, tests green, build clean — and somewhere a queue stops receiving messages, because that class name was an address and nothing in the toolchain knew.
Part 5 showed that Inflow's queue names are eight hand-written literals. This part is about the other two thirds of the address — the exchange and the routing key — which are not hand-written at all.
Where the address comes from
CustomConventionsBuilder implements three methods for Convey. Two of them decide where a message goes.
public string GetRoutingKey(Type type)
{
var routingKey = type.Name;
if (_options.Conventions?.MessageAttribute?.IgnoreRoutingKey is true)
{
return WithCasing(routingKey);
;
}
var attribute = GeAttribute(type);
routingKey = string.IsNullOrWhiteSpace(attribute?.Key) ? routingKey : attribute.Key;
return WithCasing(routingKey);
}
Precedence: the attribute's Key if it is set, otherwise type.Name, then casing. And none of the ten [ExternalMessage] usages on the branch sets Key. The escape hatch from type-name-as-contract exists in the signature, in the attribute, and in the precedence logic, and is never used. So unconditionally, across this entire system: the routing key is the C# class name of the record, snake-cased. CustomerVerified becomes customer_verified.
(The stray unreachable ; after the early return is in the source as published, and the private helper really is spelled GeAttribute. Neither matters; both are the kind of thing you notice when you read a file that nobody has read since it was written.)
GetExchange works the same way, with a different order:
var exchange = string.IsNullOrWhiteSpace(_options.Exchange?.Name)
? type.Assembly.GetName().Name
: _options.Exchange.Name;
// ... then the attribute's Topic wins if set
Attribute Topic, else configured exchange.name, else the assembly name. Both hosts set exchange.name, so the assembly fallback never fires — but note the shape: the exchange can come from configuration or from source, and the two are consulted in opposite priority to how the queue name is resolved.
The same key, computed twice, by strangers
Now follow one event across the wire. The extracted Customers service publishes CustomerVerified; the Payments module consumes it. Here are both declarations, in full, from origin/microservices:
// Inflow.Services.Customers.Core/Events/CustomerVerified.cs — the producer
public record CustomerVerified(Guid CustomerId) : IEvent;
// Inflow.Modules.Payments.Core/Deposits/Events/External/CustomerVerified.cs — the consumer
[ExternalMessage("customers", queue: "payments-module/customers-service.customer_verified")]
internal record CustomerVerified(Guid CustomerId) : IEvent;
Two records, two assemblies, no reference between them, no shared contracts package. That is deliberate — it is Inflow's “local contracts” principle, and it is the reason the modules have no project references at all. But look at where each side gets its address:
| Exchange from | Routing key from | Queue from | |
|---|---|---|---|
| Producer (no attribute) | appsettings.json, rabbitMq.exchange.name = customers |
its own type.Name → customer_verified |
n/a |
| Consumer | [ExternalMessage("customers")] in source |
its own type.Name → customer_verified |
hard-coded literal |
The exchange is configuration on one side and a source attribute on the other. The routing key is computed independently on both sides from the spelling of two unrelated C# identifiers. Nothing reconciles them.
They match because someone typed the same eleven characters in two projects. Rename either copy — the producer's record to CustomerIdentityVerified, say, or the consumer's, in a tidy-up — and the publisher starts routing to customer_identity_verified while the queue stays bound to customer_verified. The build is green on both sides. The tests pass on both sides. The broker accepts the publish, matches no binding, and drops the message, because a topic exchange with no matching binding is not an error condition. You find out from a support ticket.
The mechanism, one library down, is worth naming: Convey's publisher resolves conventions from _conventionsProvider.Get(message.GetType()). That is why SendAsync(IMessage message, ...) works at all despite T binding to the interface — the runtime type is what is consulted. Under a 1.0.* floating pin, every routing key in this system depends on a third-party library preferring GetType() to typeof(T).
Everything is published, and most of it to nowhere
There is no filter. As Part 3 showed, MessageBroker.PublishAsync sends every message to _brokerClient with no check for [ExternalMessage], and GetExchange falls back to the configured exchange name when there is no attribute, while GetRoutingKey falls back to type.Name.
So on the Bootstrapper, every purely internal event in the monolith — WalletAdded, DepositCompleted, FundsAdded, every wallets, payments and users domain event — is serialised, published to the inflow topic exchange under a snake-cased key derived from its class name, and dropped, because nothing is bound there. The in-process bus carries the real traffic; the broker carries a complete shadow copy of it that goes nowhere.
For a demo on one machine that is invisible. In a system of any size it is a doubled serialisation cost, a doubled network cost, an accidental firehose of internal domain events onto a shared broker, and — the part I would actually worry about — an information-disclosure surface, because anything with permission to bind a wildcard queue to that exchange now receives your entire internal event stream. The fix is one if and one GetCustomAttribute call in the publish path.
Two hosts declaring each other's exchanges
The last file in the adapter, CustomRabbitMqInitializer, is an IHostedService that declares exchanges at startup. It finds them by reflection:
var exchanges = AppDomain.CurrentDomain
.GetAssemblies()
.SelectMany(a => a.GetTypes())
.Where(t => t.IsDefined(typeof(ExternalMessageAttribute), false))
.Select(t => t.GetCustomAttribute<ExternalMessageAttribute>()?.Topic ?? string.Empty)
.Distinct()
.ToList();
Every [ExternalMessage] in the process is on a consumed copy of a record, so what each host collects is the set of exchanges it wants to listen to, and it then declares its own configured exchange plus each of those. The Bootstrapper collects {customers} and declares inflow (from config) and customers (from attributes). The service collects {inflow} and declares customers (from config) and inflow (from attributes). Both hosts declare both exchanges, from opposite directions.
That is idempotent only while the parameters agree, and they are supplied differently on the two paths. The configured exchange goes through the four-argument overload, passing Durable and AutoDelete from options. The attribute-derived ones go through channel.ExchangeDeclare(exchange, "topic", true) — three arguments, autoDelete and arguments left to their defaults, type hard-coded. Change "type": "direct" or "autoDelete": true in one host's config and the two processes issue conflicting exchange.declare calls; the second one gets PRECONDITION_FAILED, its channel dies, and the reason will be in a broker log rather than an application one. Exchange declaration parameters are contract too, specified twice, with different defaulting strategies, and unchecked.
Two smaller edges in the same file. The guard if (!exchanges.Any()) return; sits above the declaration of the configured default exchange, so a host that only ever produces events — zero [ExternalMessage] types — would declare nothing and 404 on its first publish. It does not fire today because both hosts happen to consume something. And the whole scan runs over AppDomain.CurrentDomain.GetAssemblies(), which on the Bootstrapper is the set of module DLLs that the assembly loader chose to load, which is a function of a filename split and a JSON boolean. There are five indirections between module.customers.json and durable topology on a shared broker.
A magic string, and a casing that only runs one way
Finally, the transform itself:
_snakeCase = options.ConventionsCasing?.Equals("snakeCase",
StringComparison.InvariantCultureIgnoreCase) == true;
No validation, no else, no log. Write "snake_case" or "snakecase" in appsettings.json — both entirely reasonable guesses — and casing is silently disabled, every routing key becomes CustomerVerified instead of customer_verified, and every binding in the system stops matching, at runtime, with no error anywhere.
And WithCasing is applied on the publish path only. The initializer declares options.Exchange.Name verbatim. Set "name": "Inflow" and the initializer declares an exchange called Inflow while the publisher targets inflow. Same file, same class, one transform, two paths, applied to one of them.
The rule
Deriving addresses from types is a defensible design — it guarantees producer and consumer compute the same key from the same rule, and it puts the wire address next to the data shape it describes. Inflow implements that idea cleanly, and I would rather read this than an XML routing table.
But the rule bottoms out in type.Name, and that has a consequence you have to accept explicitly, so I will state it as a rule of thumb: if your routing key is a class name, then your rename refactoring is a deployment, and your class names are a published API. Which means they need the things published APIs get — a deprecation path, a compatibility window, and something that fails the build when they change. This branch has an unused Key property on an attribute that would have provided exactly that, and ten opportunities where nobody used it.
Next, the envelope: the message id that is carefully put on the wire, faithfully delivered, and thrown away with an underscore.