A Shadow Registry of Every Message
Operations subscribes to eighty estate messages from one hand-maintained JSON file - a shadow copy of every contract in the system that nothing validates, with ghost entries nobody publishes and real rejections it never lists.
Part 6 read the estate's one formal contract: a single pact covering a single HTTP interaction between two services. But HTTP is the minority transport in Pacco. The estate's real conversation happens over RabbitMQ — commands, events, and rejected events flowing between eight services — and that conversation has a contract artefact too. It is a file called messages.json, it lives inside Pacco.Services.Operations.Api, and it is a hand-typed shadow copy of every message contract in the system. It is simultaneously the cleverest artefact in the estate and the most quietly rotten, and this part is about both halves.
Eighty names in one file
Operations is the estate's nervous system: it watches every message go by, tracks each user-initiated operation in Redis, and pushes progress to clients over SignalR. To do that it must subscribe to everything — and in a polyrepo estate with no shared contracts package, “everything” is a problem. Operations references no other service's code. It cannot import ResourceReserved from Availability, because nothing in Pacco imports anything from anyone.
The answer is messages.json: eight blocks, one per bus-connected service, each naming an exchange and listing that service's messages in snake_case wire form. Here is the first block, verbatim:
"availability-service": {
"exchange": "availability",
"commands": [
"add_resource",
"delete_resource",
"release_resource",
"reserve_resource"
],
"events": [
"resource_added",
"resource_deleted",
"resource_reservation_released",
"resource_reservation_canceled",
"resource_reserved"
],
"rejectedEvents": [
"add_resource_rejected",
"delete_resource_rejected",
"release_resource_rejected",
"reserve_resource_rejected"
]
}
Count the whole file and you get eighty message names — 24 commands, 29 events, 27 rejected events — across eight services (Pricing is absent, correctly: it is the estate's one bus-less service; both gateways and Operations itself are absent too). Eighty names is the entire estate-wide vocabulary, in one place, greppable. No other artefact in the thirteen repositories — not the READMEs, not the .rest scenarios, not the compose files — comes close to this density of architectural truth. That is the genius half.
From JSON to eighty queues, with no classes
What Operations does with the file is the part the estate arc admires at length, so here it is at speed. At startup, Subscriptions.SubscribeMessages deserialises the file and then manufactures the CLR types it needs at runtime:
// Pacco.Services.Operations.Api/Infrastructure/Subscriptions.cs (trimmed)
foreach (var message in messages)
{
var typeBuilder = moduleBuilder.DefineType(message, TypeAttributes.Public, type);
var constructorInfo = typeof(MessageAttribute)
.GetConstructor(new[] {typeof(string), typeof(string), typeof(string), typeof(bool)});
typeBuilder.SetCustomAttribute(new CustomAttributeBuilder(constructorInfo,
new object[] {exchange, null, null, true}));
var newType = typeBuilder.CreateType();
yield return Activator.CreateInstance(newType) as T;
}
Each snake_case string becomes an emitted type named by that string — a class literally called resource_reserved — deriving from an empty marker (Command, Event, or RejectedEvent), decorated with Convey's [Message] attribute bound to the right exchange. Convey's snake_case conventions then derive the routing key straight from the type name, a queue is declared per message under operations-service/{exchange}.{message}, and one of three generic handlers receives everything. The emitted types have no properties at all; the handlers never look at the payload. They read only headers — correlation id, context name, user id, and a saga hint we will meet in part 9 — and update the operation's state. One JSON file, eighty queue bindings, zero contract classes, three handlers. As a solution to the shared-contracts problem it is genuinely elegant, and I mean that without irony.
But notice what the elegance rests on. The file is now a registry: a second, hand-maintained copy of every contract in the estate. And the first rule of second copies is that they drift.
Where the registry lies
I checked all eighty names against the source of the eight services. Most are accurate. These are not:
| Registry entry | What the source says | Consequence |
|---|---|---|
release_resource (command) |
No such message. Availability's command is ReleaseResourceReservation — wire name release_resource_reservation |
Operations binds a queue for a message that cannot exist |
reserve_resource_rejected |
No such class anywhere in Availability | Another queue bound to nothing |
sign_in (command) |
The SignIn class exists — but Identity's bus subscriptions are one line: SubscribeCommand<SignUp>(). Sign-in is HTTP-only |
A documented command channel that leads nowhere - a sign-in published to the bus is consumed by nobody but Operations itself |
(missing) release_resource_reservation_rejected |
The rejection Availability actually publishes when a reservation fails | Operations never hears it — see below |
(missing) add_delivery_registration_rejected |
Published by Deliveries' exception mapper | Same silence |
(missing) make_order |
The ordermaker-service block has no commands array at all |
The command that starts the estate's whole saga is untracked |
The first two are ghosts — the registry names messages the source cannot produce. They fail gently: RabbitMQ is perfectly happy to bind a queue for a routing key nobody will ever publish, so Operations carries two dead queues forever and no log line ever says so.
The missing entries fail worse, and the reservation one is the sharpest. When a ReserveResource command fails in Availability — the resource is missing, the customer is invalid, an expropriation is refused — the service's ExceptionToMessageMapper publishes a rejection, and its name is not what you would guess:
// Pacco.Services.Availability.Infrastructure/Exceptions/ExceptionToMessageMapper.cs (trimmed)
ResourceNotFoundException ex => message switch
{
ReserveResource command => new ReleaseResourceReservationRejected(
command.ResourceId, command.DateTime, ex.Message, ex.Code),
...
Every failed reserve is published as release_resource_reservation_rejected — and that name appears nowhere in messages.json. So the estate's most important failure signal, the one that says “your booking did not happen”, never reaches the nervous system. A client that started a reservation through the async gateway gets an operation that goes Pending and stays Pending for as long as it cares to poll. Nothing crashed. Nothing logged. The registry simply never knew the name.
There is even one entry where the drift is accidentally honest: Orders' block omits complete_order_rejected, and the CompleteOrderRejected class does exist in Orders' source — but nothing publishes it, so for once the registry's omission and the code's dead weight agree.
Every failure mode is silence
What makes this drift so durable is that the whole pipeline is built — reasonably, line by line — to fail quietly. If messages.json is missing, SubscribeMessages returns without subscribing. If a block's commands array is absent, BindMessages yields nothing. If a name is wrong, a queue binds and sleeps. If a name is missing, an outcome evaporates and an operation polls forever. There is no validation anywhere: no test reads the file, no build step compares it to the source of the eight services, and the runtime treats every discrepancy as normal operation. The registry cannot be caught lying because nothing ever cross-examines it.
This is the estate's contract-governance story in miniature, and it rhymes with everything else in it. The pact from part 6 governs one interaction and cannot be delivered; the copied event classes are governed by nothing but diligence — the estate arc's headline find, the wrong-exchange bug, is what diligence eventually misses; and the one artefact that looks like a registry is write-only. My distilled rule from reading it: a registry nothing validates is documentation wearing a registry's clothes — it will be exactly as current as the last developer who remembered it exists.
To be fair to the authors, consider the alternatives available to a netcoreapp3.1-era sample. A shared contracts package is the coupling this whole architecture was built to escape — Convey exists because DShop.Common became a bottleneck. A schema registry is real infrastructure with real operational weight, absurd for a teaching estate. Against those options, one JSON file that buys a live operations dashboard over the entire system — eighty subscriptions for the cost of a text file — is a spectacular power-to-weight ratio. Drift is the interest on that loan, not a moral failing. But interest compounds, and the file's five verified lies were presumably all true the day each line was written.
The fix, in any real estate, is not a better file — it is making the file lose an argument with the code: a test in Operations that walks the eight sibling checkouts (the layout already exists; part 6's pact depends on it) and diffs the message inventory against the registry, failing on ghosts and omissions alike. Fifty lines, and the shadow registry becomes an actual one.
Contracts are one axis of the consumer tax; the style of the events flowing through those contracts is another, and the estate cannot agree on it with itself. Next: two ways to tell a service something changed — Deliveries ships events that carry facts, Vehicles rings a doorbell with an id on it, and a mirror between two services syncs deletions but never creations.