Skip to content
Kumar Chandrachooda
Microservices

Rejected Events and How They Rot

Convey's IRejectedEvent gives Pacco an application-level negative acknowledgement - a rejected twin for every command. Reading the four exception-to-message mappers shows the pattern decaying in four different ways, from a failed reserve announced as a failed release to rejection classes nothing ever constructs.

By Kumar Chandrachooda 18 Nov 2025 7 min read
A reply channel bending away from the message it should answer

Part 6 followed a happy answer through the estate — ResourceReserved finding its order by natural key. This part follows the unhappy ones. When a command arrives over RabbitMQ and the handler throws, there is no HTTP response to carry the 400; somebody upstream is waiting on a bus that only speaks in messages. Pacco's answer is the rejected event: for every command a <command>Rejected twin, published to the same exchange, carrying a Reason and a Code. It is the application-level Invalid Message Channel — a negative acknowledgement with business semantics.

The mechanism is genuinely elegant. The decay is what this article is about, because I read all four exception-to-message mappers in the core domain — Availability, Orders, Parcels, Customers — and every single one has rotted in a different way. Together they form a taxonomy of how error channels decay when nothing exercises them.

The mechanism, in one class

Each service registers an IExceptionToMessageMapper with Convey's RabbitMQ subscriber. When a handler throws, the mapper receives the exception and the in-flight message, and returns the event to publish instead of the reply that will never come:

public object Map(Exception exception, object message)
    => exception switch
    {
        CannotDeleteOrderException ex => new DeleteOrderRejected(ex.OrderId, ex.Message, ex.Code),
        // ...exception type × message type → rejected event
        _ => null,
    };

The convention pairs with the naming grammar of the whole estate: DeleteParcel fails as DeleteParcelRejected, AddResource as AddResourceRejected, each carrying the id it failed on plus a snake_case Code derived from the exception. A saga or gateway subscribed to the twin gets a machine-readable “no” instead of a timeout. Convey marks these with IRejectedEvent, and the same codes surface in the HTTP error envelope, so the two transports refuse in the same vocabulary.

That is the design. Now the four strains of rot, in ascending order of subtlety.

Strain one: the wrong name — a reserve rejected as a release

Availability's mapper, Pacco.Services.Availability.Infrastructure/Exceptions/ExceptionToMessageMapper.cs, handles five different failures of the ReserveResource command — expropriation refused, customer missing, customer invalid, resource missing, access denied — and maps every one of them to the same event:

CannotExpropriateReservationException ex => message switch
{
    ReserveResource command => new ReleaseResourceReservationRejected(command.ResourceId,
        command.DateTime, ex.Message, ex.Code),
    _ => null
},

Read the class name again. A failed reserve is announced to the bus as ReleaseResourceReservationRejected — a failed release. There is no ReserveResourceRejected class anywhere in the service. And the confusion is not hypothetical drift between two spellings of the same idea, because an almost identical sibling ReleaseResourceRejected also exists and is used for actual ReleaseResourceReservation failures. The two names a consumer must distinguish are ReleaseResourceReservationRejected (means: your reservation failed) and ReleaseResourceRejected (means: your release failed). Any subscriber reasoning from names alone — which is exactly how the copied-contract convention works — will wire the compensation to the wrong channel.

Strain two: the match that never fires

Orders' mapper is the most sophisticated in the estate — a two-level pattern match on exception type and message type, fanning OrderNotFoundException out to seven different rejections depending on what was being attempted. It even rejects in response to events: an order missing during DeliveryCompleted handling publishes OrderForDeliveryNotFound, which is the pattern quietly generalising beyond commands.

And then there is this arm:

OrderHasNoParcelsException ex
=> message switch
{
    AddParcelToOrder m => new AssignVehicleToOrderRejected(m.OrderId, m.ParcelId, ex.Message, ex.Code),
    _ => null
},

OrderHasNoParcelsException is thrown in exactly one place in the service: AssignVehicleToOrderHandler, when you try to assign a vehicle to an empty order. But the mapper matches it against AddParcelToOrder — a command that can never throw it (adding a parcel is how an order stops having no parcels). The real thrower, AssignVehicleToOrder, falls through to null. Two consequences stack:

  1. The reachable failure produces no rejection at all. A bus-delivered AssignVehicleToOrder against an empty order throws, maps to nothing, and whoever is waiting for AssignVehicleToOrderRejected waits forever.
  2. The unreachable arm is also wrong in itself — it builds AssignVehicleToOrderRejected(m.OrderId, m.ParcelId, ...), passing a parcel id in the vehicle-id position. Even if the branch could fire, it would emit a well-typed lie.

This is the purest illustration of why these mappers rot: the compiler checks that the arms are well-typed, not that the exception–message pairing is reachable. A pattern match over two open sets is a truth table nobody audits.

Strain three: the dead letters

Still in Orders: the classes CompleteOrderRejected and DeliveringOrderRejected exist in Application/Events/Rejected/, shaped and named exactly per the convention — and are never constructed anywhere in the repository. They are aspirational contract. Meanwhile CannotChangeOrderStateException, thrown by all four state transitions on the Order aggregate, appears in the mapper not at all. An invalid transition triggered from the bus — say DeliveryStarted arriving for an order that was cancelled in the meantime — throws, maps to null, and the message is retried against an aggregate that will refuse it identically every time. The rejection vocabulary for the order lifecycle was designed, half-built, and never wired; what runs in its place is a retry loop.

Strain four: the reply that answers a different question

Parcels' mapper shows the correlation half of the pattern decaying. Its AddParcelRejected class carries only Reason and Codeno parcel id at all — so a consumer holding three in-flight AddParcel commands cannot tell which one just failed. DeleteParcelRejected for a missing parcel is built with Guid.Empty in the id position, a rejection correlated to nothing (Availability does the same for missing tags, and its AddResource constructor even self-heals empty GUIDs on the way in, so the empty id can never be matched to any command). And UnauthorizedParcelAccessException maps to AddParcelRejected unconditionally — including when the operation being refused was a delete. Compare Orders, whose rejections always carry the ids from the in-flight message: two adjacent services, same convention, opposite discipline.

Customers has the same disease in its subtlest form. Its mapper ends with:

CustomerNotFoundException ex => new CompleteCustomerRegistrationRejected(ex.Id, ex.Message, ex.Code),

Unconditional — no inner match on the message. But CustomerNotFoundException is thrown by three handlers: registration, admin state changes, and OrderCompletedHandler, which reacts to Orders' OrderCompleted event to count loyalty orders. A completed order arriving for a customer that does not exist therefore publishes a registration rejection — an event whose name asserts that someone just failed to register. Anything subscribed to registration outcomes now receives phantom failures for customers who never filed the command. The over-broad match is the mirror image of Orders' too-narrow one: one never fires, the other fires on the wrong channel.

Why this channel rots faster than the happy path

None of these five defects would survive a single end-to-end test that asserted on a rejected event. The estate has none — the only services with behavioural tests exercise happy paths — and that is not incidental to the pattern but structural. Rejected events sit at the intersection of three things that each degrade independently: an exception taxonomy that grows with every new guard clause, a message set that grows with every new command, and a hand-maintained cross-product in the mapper pretending to keep up with both. Nothing in-shard subscribes to any *Rejected event, so the channel has no consumer to complain when it lies. The naming grammar these twins belong to — snake_case routing keys, per-context exchanges — is mapped in the estate's grammar, and the grammar holds; it is the semantics underneath that drifted.

To be fair to the authors: the pattern itself is the right call, and the estate deserves credit for having an application-level refusal vocabulary at all — most messaging codebases I have inherited just log and drop. The rot is the maintenance model, not the idea. My distilled rules from this read:

  • A rejection is a contract with a correlation obligation. If it cannot name the id it refuses, it is a log line wearing an event's clothes.
  • Map by thrower, not by plausibility. Every arm in an exception-to-message mapper should be traceable to a throw site; an arm you cannot trace is either dead or wrong.
  • Test the “no” path once per command. One assertion per rejected twin — that it fires, from the real thrower, with the real ids — would have caught all four strains here.
  • If you would retry it, don't reject it; if the sender must give up, reject it loudly and correlatably.

The rejected events at least fail at the boundary, where a refusal belongs. The next defect lives deeper — inside the aggregate itself, where Order.DeleteParcel publishes a fact that never happened, and two bounded contexts end up permanently disagreeing about whether a parcel is in an order. Next: the parcel that wouldn't leave.