What the Framework Doesn't Have
An audit of the messaging patterns DShop.Common leaves out - no outbox, no inbox, no consumed dead-letter - and how each absence became a Convey package.
The last two parts were about a retry path the estate has but doesn't use. This part is about the things it doesn't have at all — and I want to argue that the absences are the most useful map in the whole library, because you can lay them directly over Convey's package list and watch a framework's future take shape. Reading what a system leaves out tells you what its authors learned they needed. DShop.Common's gaps are Convey's table of contents.
A quick honesty note on method: every absence below I verified by grepping the source, not by inferring from the reports. There is no Outbox, Inbox, IdempotentConsumer, or dead-letter consumer type anywhere in DShop.Common. These are absences of fact, not oversights of reading.
The four things a message bus is supposed to have
Here is the reliability checklist a production messaging layer eventually grows, and where DShop.Common stands on each.
No transactional outbox. A handler in this estate writes to MongoDB and publishes to RabbitMQ as two independent operations:
await _repository.AddAsync(order); // Mongo
await _busPublisher.PublishAsync(orderCreated, context); // RabbitMQ
If the process dies between those two lines, the order exists and the event never fired — or, reorder them, and the event fires for an order that was never saved. There is no shared transaction (Mongo and RabbitMQ don't share one) and, more to the point, no outbox: no table where the event is written in the same transaction as the state change and relayed to the broker afterwards. The dual-write problem is simply present, unmitigated. This is the single biggest gap, and it is the one Convey closed most deliberately — the two-outboxes-one-interface design in the Convey series is the direct answer to this paragraph.
No inbox / idempotent receiver. Delivery here is at-least-once (RabbitMQ redelivers on nack; the in-process Polly retry re-runs the handler body), and consumers compete on a shared queue. That combination guarantees a handler will occasionally see the same message twice. DShop.Common provides nothing to deduplicate it — no store of processed message ids, no IHasBeenProcessed check. Every handler must be idempotent by hand or corrupt state on redelivery, and the framework never mentions this obligation. The second series shows exactly where a service forgot: a stock-reservation handler that decrements inventory in a loop with no dedup key, so a redelivery double-decrements.
No consumed dead-letter. The naming conventions from part two cheerfully name an error exchange — orders.error — for every service. Nothing in DShop.Common binds a queue to it or consumes it. A message that exhausts its retries throws, and where it lands is left to the broker's default behaviour; the estate's own code has no poison-message handler, no quarantine queue anyone watches, no alert. The error channel is named and unstaffed.
No message TTL, no format indicator. Messages carry no time-to-live, so a backed-up queue serves stale commands indefinitely. And there is no Format Indicator — no schema version, no message-format field. A message's identity is its routing key, which part two derives from its class name. Change the class's shape without changing its name and the routing key is identical while the payload is not — a silent, unversioned contract break. The second series opens on precisely this: an OrderCreated that means {Id, CustomerId, Products} in the service that publishes it and {Id, CustomerId} in the service that consumes it, with nothing to flag the mismatch.
The absence table is the roadmap
Line the gaps up against what came next and the pattern is unmistakable:
| Missing in DShop.Common | The pattern it needed | Where it landed |
|---|---|---|
| Transactional outbox | Guaranteed publish after state change | Convey.MessageBrokers.Outbox |
| Idempotent receiver | Dedup on message id | inbox/processed-message handling |
| Consumed dead-letter | Poison-message quarantine | broker dead-letter support |
| Format indicator | Versioned message contracts | message-typing conventions |
Every row is a package or convention the same authors wrote after living with its absence here. That is what makes DShop.Common such a good thing to read: it is a framework caught in the exact moment before it knew what it was missing, by people who then went and built the missing parts in public.
To be fair to 2018
It would be cheap to file all of this as “the framework is incomplete.” In 2018, transactional outboxes were a pattern you found in Gregor Hohpe's book and a handful of blog posts, not a NuGet package you added in an afternoon; idempotent consumers were understood but rarely scaffolded; “just make your handlers idempotent” was, and often still is, the honest state of the art. This is teaching code whose job was to show nine services talking over a bus, and at that job it succeeds completely. The absences are not failures of care. They are the frontier of what the authors had internalised at the time — which is exactly why watching them get filled, package by package, in the framework that followed is the most instructive thing this estate offers.
The rule I keep coming back to, the one the whole lens catalogue is built on: an audit earns its keep by the boxes it leaves unchecked. Reading a system for what it has tells you how it works today. Reading it for what it lacks tells you where it is going. DShop.Common is going to become Convey, and it told us so in the shape of its gaps.
Enough about messaging. The next few parts move to the other half of the framework — the in-process dispatch layer. First, the hand-rolled MediatR that routes commands and queries inside a single process, in about thirty lines and one dynamic.