Eight Queue Names Hold the Fan-Out Together
Inflow's queue template would have given three modules one shared queue and turned publish-subscribe into competing consumers. Eight hand-written string literals in three projects are what prevent it - and forgetting a ninth would not be an error, just silence.
Two consumers accidentally sharing one queue is not a bug you see. It is a bug you get half the time. Every event still gets handled, no exception is thrown, no log line looks wrong, and your integration test — which runs one consumer — passes forever. In production, half your wallets get created and half your payment accounts do, and the halves are different.
Part 4 walked three of the five files in Inflow's RabbitMQ adapter. This part is one method of the fourth, CustomConventionsBuilder.GetQueue, because on this branch it is the single thing standing between a working fan-out and that failure.
A queue name is a choice between two patterns
In AMQP, a subscriber does not subscribe to a topic. It declares a queue, binds that queue to an exchange with a routing key, and consumes. The queue name is therefore not cosmetic — it is the entire distinction between two Enterprise Integration Patterns:
- One queue per logical subscriber is a Publish-Subscribe Channel. The exchange copies each message into every bound queue; every subscriber sees every event.
- One queue, many consumers is Competing Consumers. The broker hands each message to exactly one consumer. That is the correct pattern for scaling one logical subscriber across N instances, and catastrophically wrong for wiring different subscribers together.
Same exchange, same routing key, same code — the queue name decides which one you got. And nothing in SubscribeEvent<T>() mentions a queue.
The template that would have been fine
CustomConventionsBuilder computes the queue name, and its default lives in the constructor:
_queueTemplate = string.IsNullOrWhiteSpace(_options.Queue?.Template)
? "{{assembly}}/{{exchange}}.{{message}}"
: options.Queue.Template;
Then GetQueue(Type) substitutes:
var assembly = type.Assembly.GetName().Name;
var message = type.Name;
var queue = _queueTemplate.Replace("{{assembly}}", assembly)
.Replace("{{exchange}}", exchange)
.Replace("{{message}}", message);
Look at what {{assembly}} buys. The consumer's copy of an external event record lives in the consuming module's own assembly — Payments' CustomerVerified is in Inflow.Modules.Payments.Core, Wallets' is in Inflow.Modules.Wallets.Application, Saga's is in Inflow.Modules.Saga.Api. Three different assemblies, therefore three different queue names, therefore three queues bound to the same exchange with the same routing key. The default template produces a correct Publish-Subscribe Channel by construction, because the assembly name is a natural proxy for “which logical subscriber is this”.
That is a nice piece of library design, and it is Convey's, not Inflow's.
The one token that was replaced
Inflow's Bootstrapper overrides the template in appsettings.json:
"queue": {
"declare": true,
"durable": true,
"exclusive": false,
"autoDelete": false,
"template": "inflow/{{exchange}}.{{message}}"
}
The {{assembly}} token is gone, replaced by the constant inflow. Everything the default template did to keep subscribers apart depended on that token, and it is the only thing the configuration changed.
Trace CustomerVerified through the substitution as the Bootstrapper would compute it, for all three consuming modules. Exchange resolves to customers (from the [ExternalMessage("customers")] attribute); message is CustomerVerified; casing is snake, since conventionsCasing is snakeCase:
Payments -> inflow/customers.customer_verified
Wallets -> inflow/customers.customer_verified
Saga -> inflow/customers.customer_verified
One queue. Three consumers. Every CustomerVerified published by the extracted Customers service would be delivered to exactly one of Payments, Wallets or Saga, chosen by the broker's round-robin, and the other two would never see it. No error, no warning; RabbitMQ is doing precisely what it was asked.
Eight literals that undo the configuration
They do not compute it, though, because GetQueue checks the attribute first:
var attribute = GeAttribute(type);
var ignoreQueue = _options.Conventions?.MessageAttribute?.IgnoreQueue;
if ((ignoreQueue is null || ignoreQueue == false) && !string.IsNullOrWhiteSpace(attribute?.Queue))
{
return WithCasing(attribute.Queue);
}
An explicit queue: on the attribute wins outright. And every one of the eight consumed event records in the monolith carries one:
payments-module/customers-service.customer_completed
payments-module/customers-service.customer_locked
payments-module/customers-service.customer_unlocked
payments-module/customers-service.customer_verified
wallets-module/customers-service.customer_completed
wallets-module/customers-service.customer_verified
saga/customers-service.customer_completed
saga/customers-service.customer_verified
Eight string literals, spread across three projects, in [ExternalMessage(...)] attributes on eight record declarations. They are the entire mechanism by which this system has a working fan-out, and they are re-implementing by hand exactly the isolation the default template gave away.
The shape of the names is deliberate and worth reading: <consumer>/<producer>.<message>. It is a good convention — you can look at a queue in the management UI and know both ends of the wire. The private SnakeCase helper is built to preserve it:
private static string SnakeCase(string value)
=> string.Concat(value.Select((x, i) =>
i > 0 && value[i - 1] != '.' && value[i - 1] != '/' && char.IsUpper(x) ? "_" + x : x.ToString()))
.ToLowerInvariant();
The . and / guards exist so that snake-casing inflow/customers.CustomerVerified yields inflow/customers.customer_verified and not inflow/customers._customer_verified. Somebody thought about the grammar of these names carefully. The same person also configured away the token that made them unnecessary.
Three vocabularies for one service
Now put the literals next to what the template would have produced. The template writes {{exchange}}, which resolves to customers. The literals say customers-service. Those are not the same string, and both are correct — because this branch has three coexisting names for things in the same region of the system:
| Vocabulary | Where it appears |
|---|---|
inflow |
Bootstrapper exchange name, Bootstrapper queue prefix, the service's [ExternalMessage("inflow")] topics |
customers |
Service exchange name, the topic on all eight module-side attributes |
customers-service |
Service connectionName, service queue-template prefix, database name, YARP route prefix, and the middle segment of all eight queue literals |
The template is effectively dead for these eight messages, which is fortunate, because if it ever fired it would produce names in a different vocabulary from every other queue in the system.
Renaming the service is the test of how much this costs. You would edit connectionName, exchange.name, queue.template, the Postgres database name, the YARP route and cluster ids — and then eight string literals in three projects the service does not reference and cannot see. Nothing tools that. Nothing checks it.
The failure mode of forgetting the ninth
Here is the part that would keep me up. Adding a ninth cross-service subscriber is a two-line change: an [ExternalMessage("customers")] on the consuming module's copy of the record, and a .SubscribeEvent<T>() in that module's Use. That ergonomic minimalism is, as Part 3 argued, the branch's best result.
But the attribute's queue: argument is optional, and ExternalMessageAttribute's constructor defaults all three arguments to null. Omit it and you do not get a compile error, a startup failure, a log line, or a validation exception. You get a queue name computed from the template, colliding with whichever other module also forgot, and a fan-out that silently becomes competing consumption for that one event type.
The correctness of the whole integration rests on developers remembering an optional string argument, and the failure mode of forgetting is not an error but a coin flip.
What I would do instead
Three cheap changes, in ascending order of how much I would insist on them.
- Delete the template override.
inflow/{{exchange}}.{{message}}is the only reason any of this is fragile. Restoring{{assembly}}makes correct behaviour the default and the eight literals optional rather than load-bearing. - Make the queue explicit or make it derived — not either. If you want hand-written queue names, require them: throw at startup when a subscribed type has no
Queue. If you want them derived, do not let an attribute override the derivation. The current precedence gives you a safe path and an unsafe path with identical syntax. - Assert the topology at boot. The subscriber knows every
Tit was asked to bind. Collecting the computed queue names and failing fast on a duplicate within one process is perhaps fifteen lines, and it converts the entire class of bug described above into a startup crash with a useful message.
To be fair to the author: none of this is wrong for a teaching branch, the naming grammar is better than most production systems manage, and the eight literals do work. But they are a hand-maintained index of a system property that the machine could have derived, and hand-maintained indexes drift.
Next, the routing key — because if the queue names are hand-written, the thing they are bound to is not: it is a C# class name, computed independently on both sides of the wire.