Queue Names Are the Whole Topology
One naming class in DShop.Common decides every exchange, routing key and queue in the estate - the message topology is a string-formatting problem.
At the end of part one I promised a naming class that decides how every message in the estate finds its way home. Here it is, and the claim in the title is not rhetorical: in DShop.Common there is no topology configuration file, no broker-provisioning script, no admin UI where someone drew the exchanges. The entire RabbitMQ layout — which exchange a message lands on, which routing key it carries, which queue a consumer competes on — is computed from the message's type by one private class of about thirty-five lines. Read that class and you have read the estate's wiring diagram.
It lives inside the RabbitMq Extensions, handed to RawRabbit (the broker client this estate is built on, version 2.0.0-rc5) as its INamingConventions:
private class CustomNamingConventions : NamingConventions
{
public CustomNamingConventions(string defaultNamespace)
{
var assemblyName = Assembly.GetEntryAssembly().GetName().Name;
ExchangeNamingConvention = type => GetNamespace(type, defaultNamespace).ToLowerInvariant();
RoutingKeyConvention = type =>
$"{GetRoutingKeyNamespace(type, defaultNamespace)}{type.Name.Underscore()}".ToLowerInvariant();
QueueNamingConvention = type => GetQueueName(assemblyName, type, defaultNamespace);
ErrorExchangeNamingConvention = () => $"{defaultNamespace}.error";
RetryLaterExchangeConvention = span => $"{defaultNamespace}.retry";
RetryLaterQueueNameConvetion = (exchange, span) =>
$"{defaultNamespace}.retry_for_{exchange.Replace(".", "_")}_in_{span.TotalMilliseconds}_ms"
.ToLowerInvariant();
}
// ...GetNamespace, GetRoutingKeyNamespace, GetQueueName...
}
Read it slowly, because each of those six assignments is a design decision that most systems make in infrastructure and this one makes in a Func<Type, string>.
Three names from one type
Take a concrete message — say an OrderCreated event published by the Orders service, whose default namespace is orders. The three conventions turn that single type into three coordinated strings:
Exchange —
orders. TheExchangeNamingConventionreturns the message's namespace, nothing else. Every message a service owns lands on one exchange named for the service. This is the Enterprise Integration Patterns Datatype Channel taken to its logical end: not one channel per message type, but one channel per bounded context, with the message type demoted to a routing concern.Routing key —
orders.order_created.RoutingKeyConventionconcatenates the namespace with the type name, run throughUnderscore(). That helper is four lines in the rootExtensionsand does exactly what its name says:public static string Underscore(this string value) => string.Concat(value.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x.ToString() : x.ToString()));OrderCreatedbecomesOrder_Createdbecomes, after the lower-casing,order_created. The routing key is the class name, mechanically transformed. There is no attribute to set it, no registry mapping it — rename the class and you have renamed the routing key.Queue —
dshop.services.orders/orders.order_created.GetQueueNameprefixes the consuming assembly's name to the namespaced message name. This is the load-bearing detail. Because the queue name includes the assembly, every running instance of a given service binds to the same queue and they compete for messages — the Competing Consumers pattern, so scaling Orders to three replicas gives you three consumers pulling from one queue, not three copies of every message. And because a different assembly subscribing to the same event derives a different queue name, RabbitMQ's topic exchange fans the event out to each — Publish-Subscribe. Datatype Channel, Competing Consumers and Pub-Sub, all three, decided by whether the assembly name matches.
That is the whole trick. The exchange groups by producer, the routing key identifies the message, and the queue groups by consumer-assembly. Nothing else needed to know the topology; it falls out of type names.
The escape hatch, and what it reveals
Twice in that class you see GetNamespace consult a MessageNamespaceAttribute before falling back to the default:
private static string GetNamespace(Type type, string defaultNamespace)
{
var @namespace = type.GetCustomAttribute<MessageNamespaceAttribute>()?.Namespace
?? defaultNamespace;
return string.IsNullOrWhiteSpace(@namespace) ? type.Name.Underscore() : $"{@namespace}";
}
The attribute is a one-property marker:
[AttributeUsage(AttributeTargets.Class)]
public class MessageNamespaceAttribute : Attribute
{
public string Namespace { get; }
public MessageNamespaceAttribute(string @namespace)
=> Namespace = @namespace?.ToLowerInvariant();
}
It exists for exactly one situation: a service needs to consume another service's message, so it declares a local copy of that message type — but the copy lives in the consumer's namespace, which would derive the wrong exchange. [MessageNamespace("orders")] on the copy forces it back onto the Orders exchange. The attribute is the seam where “the type name is the contract” meets “but each service owns its own copy of the type”, and it is the first crack in the convention: the topology is derived from types, except when a human has to override the derivation because the types drifted. Series two of this estate is largely the story of that drift — the same event declared in two services with two different shapes — and it starts right here, with the attribute that papers over it.
When the string format is the schema
The last three conventions name the error and retry infrastructure, and they are pure string formatting:
error exchange → orders.error
retry exchange → orders.retry
retry queue → orders.retry_for_orders_error_in_2000_ms
The retry queue name encodes the delay in milliseconds. There is no metadata table recording “this queue holds messages waiting 2000ms”; the wait is literally spelled into the queue name, and the retry machinery parses it back out. It works, and it is also the kind of decision that makes an operator squint at rabbitmqctl list_queues output trying to reverse-engineer intent from string fragments. (Whether that retry path is even reachable is part six's problem — spoiler: this half of the naming class names a queue almost nothing ever fills.)
There is a real lesson under the cleverness. Convention-derived topology buys you consistency for free and takes away your ability to see the topology anywhere but in running RabbitMQ. A new service that follows the convention is wired correctly the moment it compiles — no exchange to declare, no binding to remember. But there is no file you can open to answer “what publishes to the orders exchange?” You answer it by grepping for types, or by reading this naming class and running the format strings in your head. Convey, the framework this became, keeps the convention-first idea but pulls the conventions out into a configurable, inspectable object precisely so the answer can live somewhere other than a private nested class.
If you want to know a message-based system, find its naming conventions before you find its handlers. In this estate they are thirty-five lines, and they are the map. Next, the publisher that sends commands and events down these channels — and treats the two as very nearly the same thing.