Skip to content
Kumar Chandrachooda
Microservices

The Message Map and the Wrong Exchange

The estate's full who-publishes-what matrix, built from the source - and the one broken cell in it. Orders' copied ParcelDeleted event declares the deliveries exchange while Parcels publishes on parcels, so Orders never learns a parcel was deleted, and neither the Pact tests nor the hand-maintained registry can catch it.

By Kumar Chandrachooda 19 Jan 2026 7 min read
A wiring diagram with one line plugged into the wrong socket

Delete a parcel in Pacco and any order containing it keeps the parcel forever. Not because of domain logic — Orders has a handler written for exactly this, and it does the right things in the right sequence. The handler simply never runs, in any environment, under any timing, because of one string in one attribute: a copied event class that names the wrong exchange. Part 13 closed on a queue whose grammar is perfect and whose meaning is broken; this part builds the estate's full message map from source, locates that queue on it, and then asks the question that makes the bug matter beyond its one lost event: why did nothing — not the compiler, not CI, not the contract tests, not the estate's own message registry — notice.

The map, from source

Every subscription in the estate is declared in one place per service — the SubscribeCommand<>() / SubscribeEvent<>() chain in its Infrastructure Extensions.cs — so the consumption side of the map can be read mechanically. Here it is, verified against all nine bus-connected services:

Service Subscribes commands Subscribes events (source context)
Availability add / delete / reserve_resource, release_resource_reservation CustomerCreated (customers), VehicleDeleted (vehicles)
Customers complete_customer_registration, change_customer_state SignedUp (identity), OrderCompleted (orders)
Deliveries start / complete / fail_delivery, add_delivery_registration
Identity sign_up
OrderMaker OrderApproved, OrderCreated, ParcelAddedToOrder, VehicleAssignedToOrder (orders), ResourceReserved (availability)
Orders approve / create / cancel / delete_order, add / delete_parcel_from_order, assign_vehicle_to_order CustomerCreated (customers), DeliveryStarted / Completed / Failed (deliveries), ParcelDeleted (deliveries?), ResourceReserved, ResourceReservationCanceled (availability)
Parcels add_parcel, delete_parcel OrderCanceled, OrderDeleted, ParcelAddedToOrder, ParcelDeletedFromOrder (orders), CustomerCreated (customers)
Vehicles add / update / delete_vehicle
Operations everything, via runtime-emitted types everything

Pricing appears nowhere: no broker packages, no exchange, the estate's proof that not every service needs the bus. Operations subscribes to the entire surface through the fabricated types of part 2. And one cell carries a question mark, because the source it comes from disagrees with itself.

Copy-per-consumer, counted

Pacco shares no contract assembly. Each consumer re-declares every foreign event it wants under Application/Events/External/, stamped with a [Message] attribute naming the publisher's exchange — the copy-per-consumer regime, chosen deliberately by authors who had watched a shared messages package calcify in their previous estate. I counted the copies: twenty-seven [Message("...")] declarations across the consuming services, and twenty-six of them name the right exchange. As drift rates for hand-maintained duplication go, 26 of 27 is genuinely good. But contracts are not graded on a curve, and the twenty-seventh is a beauty. Here is Orders' copy, in full, from Pacco.Services.Orders.Application/Events/External/ParcelDeleted.cs:

[Message("deliveries")]
public class ParcelDeleted : IEvent
{
    public Guid ParcelId { get; }

    public ParcelDeleted(Guid parcelId)
    {
        ParcelId = parcelId;
    }
}

And here is the original, in Parcels — Pacco.Services.Parcels.Application/Events/ParcelDeleted.cs — the class actually published when DeleteParcelHandler finishes its work:

[Contract]
public class ParcelDeleted : IEvent
{
    public Guid ParcelId { get; }

    public ParcelDeleted(Guid parcelId)
    {
        ParcelId = parcelId;
    }
}

No [Message] attribute on the original — a publisher doesn't need one; Convey routes an outgoing event through its own service's exchange, parcels, with routing key parcel_deleted. The copy in Orders says deliveries. Same class name, same shape, same routing key — wrong noun.

Anatomy of a silent failure

Follow the wiring precisely, because every step behaves correctly and the composition still fails. Orders calls .SubscribeEvent<ParcelDeleted>() (Pacco.Services.Orders.Infrastructure/Extensions.cs, line 106). Convey reads the copy's attribute and dutifully creates queue orders-service/deliveries.parcel_deleted, bound to the deliveries exchange with routing key parcel_deleted. The queue exists. The binding is real. The handler is registered. And the Deliveries service has never published, and will never publish, an event called parcel_deleted — the only publisher of that fact is Parcels, one exchange over, whose events flow past this queue without touching it.

So ParcelDeletedHandler — which loads the containing order, calls order.DeleteParcel, saves, and publishes the resulting domain events — is unreachable code in every deployed configuration. Delete a parcel that an order references and: Parcels removes it, Parcels' parcel_deleted fires, Operations (which gets the exchange right, as we'll see) marks the operation completed, the caller's SignalR push says success — and the order silently keeps a parcel that no longer exists, forever. There is no error to alert on, no dead-letter, no retry exhausting itself. An empty queue is indistinguishable from a queue whose event simply hasn't happened yet. The association was already fragile from the domain side — the parcel that wouldn't leave found bugs in both services' halves of it — but this failure is different in kind: the message that might have reconciled the two contexts never arrives at all.

Three safety nets, one hole in each

What makes this the estate's headline defect is not the bug itself — it is that Pacco has contract machinery, three kinds, and the bug threads all of them.

The compiler and CI. Both copies compile; both repos' Travis pipelines are green. The exchange name is a string in an attribute, invisible to the type system, and no build anywhere loads both repos at once — the estate-level build that could have cross-checked doesn't exist (part 10).

The Pact tests. Orders and Parcels are precisely the pair with consumer-driven contract tests — the only cross-repo verification in the estate. But the pact covers their HTTP conversation, GET /parcels/{id}; the bus contract — which exchange, which routing key, which shape — is outside Pactify's model entirely. The one relationship the estate verifies is the one relationship with a broken channel, and the tests structurally cannot see it: they check what the services say to each other, not where they say it.

The registry. The irony I promised: Operations' hand-maintained messages.json gets it right. Under parcels-service, exchange parcels, the events list includes parcel_deleted — so the nervous system's runtime-emitted subscription binds to the correct exchange and receives the event that Orders misses. The unvalidated JSON file beat the strongly-typed C# copy. But the registry is a bystander, not a net: nothing compares it against the services' declarations, and it has drift of its own in both directions — it lists a sign_in command nobody subscribes, and it is missing Orders' complete_order_rejected, which exists in source. Three artefacts describe the estate's message surface — the publishers' code, the consumers' copies, the registry — and they pairwise disagree, which is the same three-registries disease part 10 diagnosed in the repo list, now carrying business behaviour.

The exchange is part of the contract

The distilled lesson: a message contract is not just a shape — it is a shape at an address, and copy-per-consumer duplicates the address along with the shape. Field drift in a copied contract fails loudly (deserialisation breaks, values go missing). Address drift fails silently, because a wrong binding is still a valid binding. If you run the copy-per-consumer pattern — and there are honest reasons to — the address needs a check that matches its failure mode:

  • Generate the copies from the publisher's declaration (a manifest, or the publisher's own source) rather than typing them, so the exchange name has one origin.
  • Assert the topology in tests: a consumer integration test that publishes the real event through the real broker and asserts the handler fired would have caught this in minutes — it is the transport-level analogue of the Pact idea, pointed at the transport that actually carries the message.
  • Reconcile the registries: if a messages.json exists, validate it in CI against both publishers' and consumers' declarations; three sources of truth that are never compared are three opinions.
  • Alert on silence: a queue with a binding and zero deliveries over a long window is the operational signature of exactly this bug.

The map is now complete, including its one broken cell — and with it, this survey of the estate's edges, nervous system, platform and posture has visited every layer it set out to. What remains is the retrospective: what this system of systems teaches, what its absences confess, how the successor evolved past its predecessor and where it stalled. Next: cartography of a successor.