The Parcel That Wouldn't Leave
Order.DeleteParcel raises a ParcelDeleted event and never removes the parcel from the set, while Parcels' cancel and delete handlers detach exactly one parcel of many. Two bounded contexts maintain the same association independently, both with bugs, and end up permanently disagreeing about a fact.
Part 7 read the estate's machinery for saying no. This part is about a place where the estate says yes — persists, publishes, returns success — and the thing it said yes to never happens. Delete a parcel from a Pacco order and every observable signal reports completion: the handler finishes cleanly, the document is written, ParcelDeletedFromOrder goes out on the bus, and the Parcels service on the other side dutifully detaches its copy. Then query the order. The parcel is still there. It will always be there. This is the sharpest bug in the whole estate, and it is sharp precisely because it is small — one missing line inside an otherwise textbook aggregate method.
One missing line
Pacco.Services.Orders.Core/Entities/Order.cs:
public void DeleteParcel(Guid parcelId)
{
var parcel = _parcels.SingleOrDefault(p => p.Id == parcelId);
if (parcel is null)
{
throw new OrderParcelNotFoundException(parcelId, Id);
}
AddEvent(new ParcelDeleted(this, parcel));
}
Read it against its sibling AddParcel, four lines up in the same file, which calls _parcels.Add(parcel) and raises ParcelAdded only when the add succeeds. DeleteParcel performs the lookup, performs the guard — and then raises the event without ever calling _parcels.Remove(parcel). The method's entire mutation is the event. The state it describes never changes.
Everything downstream of this line is correct, which is what makes the failure so instructive. DeleteParcelFromOrderHandler loads the aggregate, runs the ownership check, calls DeleteParcel, persists via UpdateAsync — writing the document with the parcel still embedded — then maps and publishes the domain events. Orders' EventMapper translates the domain-level ParcelDeleted into the integration event ParcelDeletedFromOrder(order.Id, parcel.Id). Over in Parcels, ParcelDeletedFromOrderHandler receives it and detaches its side of the association:
public async Task HandleAsync(ParcelDeletedFromOrder @event)
{
var parcel = await _parcelRepository.GetAsync(@event.ParcelId);
if (parcel is null)
{
return;
}
parcel.DeleteFromOrder();
await _parcelRepository.UpdateAsync(parcel);
}
DeleteFromOrder sets the parcel's OrderId to null. So after the dust settles: Parcels says the parcel belongs to no order; Orders says the order contains the parcel. Two bounded contexts, one association, two answers — and because the divergence was created by a successfully processed event, no retry, no dead letter and no rejection will ever reconcile it. Event-carried state transfer has carried a state that does not exist.
The blast radius of a fact that never happened
Trace what stays wrong inside Orders once the phantom deletion lands, in ascending severity:
- Repeated deletes keep succeeding. The parcel is still in
_parcels, soSingleOrDefaultkeeps finding it, the guard keeps passing, and every retry publishes anotherParcelDeletedFromOrder— idempotent by accident, in the worst way: the operation reports success indefinitely because it does nothing. HasParcelsstays true. An order emptied of its parcels one delete at a time still claims to have them, soAssignVehicleToOrderHandler's empty-order guard — theOrderHasNoParcelsExceptionwhose broken rejection we met in part 7 — can never fire for an order emptied this way. A vehicle can be booked, priced and reserved for cargo that left months ago.GetContainingParcelAsyncstill finds it. The repository's containment query (o.Parcels.Any(p => p.Id == parcelId)) matches the dead membership, which matters because of who calls it — the externalParcelDeletedhandler, and that is where the bug compounds.- The parcel can never be re-added. Orders'
ParcelimplementsIEquatable<Parcel>by id, so theHashSetstill contains the id; a legitimateAddParcelfor the same parcel throwsParcelAlreadyAddedToOrderException. The ghost blocks the living.
Point 3 deserves its own paragraph. When a parcel is deleted outright in the Parcels service — DeleteParcelHandler, which only permits deletion when the parcel is not attached to an order — Parcels publishes its own ParcelDeleted integration event. Orders subscribes, finds any order containing that parcel via the containment query, and calls… the same broken DeleteParcel method. The one cleanup path that could have healed an order holding a dead parcel routes through the method that cannot remove anything. (Whether that event even arrives is its own story: Orders' copied contract declares [Message("deliveries")] while Parcels publishes on its own exchange — the wrong-exchange finding that the message map examines estate-wide.)
The other side has its own bug
If Orders' half of the association is broken by omission, Parcels' half is broken by cardinality. Here is OrderCanceledHandler — and OrderDeletedHandler is line-for-line identical:
public async Task HandleAsync(OrderCanceled @event)
{
var parcel = await _parcelRepository.GetByOrderAsync(@event.OrderId);
if (parcel is null)
{
return;
}
parcel.DeleteFromOrder();
await _parcelRepository.UpdateAsync(parcel);
}
Singular. GetByOrderAsync runs GetAsync(p => p.OrderId == orderId) — first match wins. But an order holds a set of parcels; Orders models it that way, and Parcels itself will happily attach several parcels to one order. When that order is cancelled or deleted, exactly one parcel is detached. The rest keep their OrderId pointing at a dead order forever — and because the default GetParcels query filters to unattached parcels, they do not merely stay wrong, they disappear from view, bound to an order that no longer exists and hidden from the listing that would reveal them.
Step back and look at the two defects together, because the symmetry is the lesson. The parcel–order association is maintained twice — once as an embedded set in Orders' documents, once as a foreign key on Parcels' documents — with no owner, reconciled only by events flowing in both directions. Orders' copy has a bug in the delete direction; Parcels' copy has a bug in the cancel direction. Neither side can detect the other's drift, because each is the authority for its own copy. When two contexts each maintain their own copy of one relationship, you have not shared a fact — you have created two facts that happen to start out equal.
Domain events must be facts
The deeper principle sits in DeleteParcel itself. A domain event is, by definition, a record of something that has happened — past tense, immutable, true. The whole downstream machinery is built on that assumption: the mapper translates it, the broker publishes it, remote contexts update their models from it, and none of them re-verify it, because re-verifying published facts would defeat the point of publishing them. AddEvent(new ParcelDeleted(...)) in a method that deletes nothing weaponises that trust. It is not lying to a log; it is lying to every service that models its world from your announcements.
To be fair to the authors, this is exactly the class of bug that a teaching estate is most prone to: the method reads correctly at review speed — lookup, guard, event — and the estate's only behavioural tests live in Availability, so nothing exercises Orders' aggregate. I would even guess the Remove call existed in some ancestral version and fell out in a refactor; the event's constructor still takes the found parcel as though someone intended to do something with it. But the durable lessons don't depend on the history:
- Raise events from mutations, not near them. The safest shape couples the two mechanically —
if (_parcels.Remove(parcel)) { AddEvent(...); }— which is precisely the discipline Availability'sResource.AddReservationfollows and this method dropped. Make the event emission conditional on the state change succeeding, every time. - One writer per relationship. If Orders owns parcel membership, Parcels should hold at most a cache of it, refreshed from Orders' events — not an independently mutated column with its own lifecycle rules.
- Assert on state, not on events, in aggregate tests. A test asserting “raises
ParcelDeleted” passes against this bug. A test asserting “the parcel is gone” fails in one line.
The irony worth sitting with: this whole disaster is a naming success. Every event here is named honestly, routed correctly (one exchange aside) and handled idempotently on the consumer side. The system fails anyway, because the event's relationship to the truth was broken at the source. Which raises the opposite question — can two services call the same fact by two different names and both be right? Customers answers yes, in the best three lines of translation in the estate. Next: same fact, two names.