Skip to content
Kumar Chandrachooda
Microservices

The Grammar of the Estate

Pacco's naming system is consistent enough to parse - exchanges are bounded contexts, queue names spell their own bindings, commands and events conjugate in snake_case, and every command has a _rejected twin. Reading the estate as a language, including its irregular verbs and the fact that no grammar-checker exists.

By Kumar Chandrachooda 16 Jan 2026 6 min read
Message names parsed into nouns and verbs on a diagram

Open RabbitMQ's management UI against a running Pacco estate, knowing nothing else about the system, and you can reconstruct most of its architecture from names alone. orders-service/availability.resource_reserved is a queue; read it and you know a service called Orders subscribes to an event called resource_reserved published by the Availability bounded context. No documentation needed — the name is the documentation. Part 12 ended on the observation that the estate's durable memory lives in its naming system, and I meant it literally: dashboards evaporate, but the names persist in code, config and the broker, and they are consistent enough to deserve the word grammar — nouns, verbs, conjugation rules, and (this being a natural language) irregular forms. This part reads the grammar end to end, because a naming system this coherent across thirteen repositories maintained by copy-paste is an achievement worth dissecting — right up to the question of what enforces it.

Nouns - the exchange is the bounded context

Every service declares one topic exchange, named for its bounded context, in its own configuration:

"exchange": {
  "declare": true,
  "durable": true,
  "autoDelete": false,
  "type": "topic",
  "name": "orders"
}

The full noun inventory: availability, customers, deliveries, identity, ordermaker, orders, parcels, vehicles — plus operations, declared in the Operations service's config and never published to (the nervous system listens to everyone and says nothing on the bus), and no exchange at all for Pricing, which has no broker packages and lives purely on HTTP. Exchange-per-context turns the broker's topology page into a context map — the strategic-DDD diagram most estates draw in a wiki and let rot exists here as live infrastructure, one noun per context, queryable at runtime.

Sentences - the queue spells its own binding

The queue naming template appears identically in all nine bus-connected services, varying only in the first segment:

"queue": {
  "template": "orders-service/{{exchange}}.{{message}}"
}

So a binding materialises as {subscriber}/{publisher-context}.{message} — subject, object and verb in one string. parcels-service/orders.order_canceled: Parcels listens for Orders' cancellations. The whole who-talks-to-whom matrix is recoverable by listing queues, which is precisely how I built the message map for the next part. One of those self-describing sentences, though, is a lie — a queue whose name promises an event that never arrives on it — and it is the best finding in this half of the series; I will leave it for part 14, because it needs the full matrix around it to land.

Verbs and their conjugation

Message names are snake_case on the wire — "conventionsCasing": "snakeCase" in every service's RabbitMQ block, with Convey deriving each routing key from the CLR type name. The convention gives the estate a real conjugation system: commands are imperative (reserve_resource, create_order, complete_customer_registration), events are past tense (resource_reserved, order_created, customer_state_changed). Tense marks intent versus fact. You can hear the difference in a log stream without knowing the domain — anything ending in _ed already happened and cannot be refused; anything imperative is a request that still might be.

And refusal has its own morphology. Every command carries a shadow twin, <command>_rejected: reserve_resource / reserve_resource_rejected, sign_up / sign_up_rejected, create_order / create_order_rejected. The twins are minted mechanically — each service's ExceptionToMessageMapper pattern-matches a thrown exception plus the in-flight command and emits the corresponding rejection event, implementing IRejectedEvent. Operations' messages.json catalogues them as a first-class category, rejectedEvents, alongside commands and events. As grammar, this is the estate's most elegant rule: the failure vocabulary is derived from the request vocabulary, so learning one verb teaches you three words — the command, the fact, and the refusal.

Irregular verbs

Natural languages keep irregular forms where history overrode the rule, and so does Pacco. Orders' Events/Rejected/ folder holds eight regular twins — and three irregulars: DeliveringOrderRejected (there is no deliver_order command for it to twin), OrderForDeliveryNotFound and OrderForReservedVehicleNotFound, rejection events named like queries that answer no command at all. They still implement IRejectedEvent and still ride the rejectedEvents list in messages.json, but the <command>_rejected derivation cannot produce them; someone hand-conjugated. There is also a ghost: CompleteOrderRejected exists in source while complete_order_rejected is absent from messages.json — a word in the dictionary that the estate's own phrasebook forgot. The deeper decay of the rejection channel — twins wired to the wrong exceptions, rejections that fire for the wrong verb — is the domain series' story in rejected events and how they rot; from the grammar's vantage the point is narrower: the moment a convention needs an exception, it needs an owner, because the mechanical rule can no longer generate the vocabulary on its own.

Headers, keys and prefixes - the grammar off the bus

The naming system does not stop at RabbitMQ. The estate conjugates consistently across HTTP, Redis and SignalR too:

Surface Form Meaning
HTTP response Resource-ID header the aggregate id the Ntrada gateway minted at the edge
HTTP response X-Operation header the status URL, in the Ocelot twin's middleware
HTTP request Correlation-Context header the identity blob, JSON-serialised
AMQP message_context header the same blob crossing the bus
Redis requests:{correlationId} an operation's state in the tracker
Redis per-service instance prefixes — identity:, operations: namespacing one Redis across services
SignalR users:{userId} groups per-user push fan-out

Two precisions. The Resource-ID and X-Operation headers are not two names for one thing but the two gateways' respective idioms — Ntrada exposes Resource-ID (with Request-ID, Trace-ID and Total-Count) in its CORS config; X-Operation exists only in the undeployed Ocelot gateway's Extensions.cs. And the {noun}:{id} shape for Redis keys and SignalR groups is the queue template's philosophy at smaller scale: a name that tells you both the collection and the member. The correlation blob's journey end to end was part 7; here it is one row in a table whose real finding is the consistency of the whole column.

The error envelope - grammar generated from code

The estate's final grammatical rule generates its error vocabulary automatically. Every service's ExceptionToResponseMapper renders failures as the same two-field envelope, {code, reason}, and derives the code from the exception's type name — from Availability's copy:

var exceptionCode = exception switch
{
    DomainException domainException when !string.IsNullOrWhiteSpace(domainException.Code)
        => domainException.Code,
    AppException appException when !string.IsNullOrWhiteSpace(appException.Code)
        => appException.Code,
    _ => exception.GetType().Name.Underscore().Replace("_exception", string.Empty)
};

ResourceAlreadyExistsException becomes resource_already_exists, cached in a ConcurrentDictionary after first derivation. The same {code, reason} pair rides the rejected events — OrderForDeliveryNotFound(orderId, reason, code) — so a failure speaks one language whether it surfaces as an HTTP 400 or a bus message. This is the grammar's cleverest trick and its most fragile: the exception class name is now a public contract. Rename ResourceAlreadyExistsException in a refactor and every client matching on resource_already_exists breaks, with no compiler, test or schema anywhere that knows the two were connected.

A grammar with no grammar-checker

Which is the closing verdict on the whole system. Everything above — exchange nouns, queue sentences, snake_case conjugation, _rejected twins, {noun}:{id} keys, derived error codes — is convention, enforced by nothing. No linter checks that an exchange matches its context; no test asserts a command has its twin; no schema pins an error code to its exception; the routing keys and queue names are derived from class names by a framework that will happily derive a wrong name from a wrong class. The grammar held across thirteen repos because a small number of disciplined authors copied carefully — the estate's usual answer, and its usual exposure. A language where every speaker must be perfect eventually produces a sentence that parses beautifully and means the wrong thing. The estate has exactly one of those: a queue whose grammar is flawless — orders-service/deliveries.parcel_deleted, subscriber, context, event, all correctly spelled — and whose meaning is broken, because the event it names is published on a different exchange entirely. Next: the message map and the wrong exchange.