Rejected Events — a Two-Lane Error Taxonomy
DShop.Common decides a message's fate from the type of exception a handler throws - domain failures become events, infrastructure failures get retried.
Part four was a bug that hides. This part is the opposite: the single best-designed thing in the messaging core, a place where reading the source raises your opinion of the estate. When a message handler fails, DShop.Common has to decide one thing — retry, or give up — and it makes that decision from the type of exception the handler threw. Domain failures and infrastructure failures go down two different lanes, and which lane you take is decided by whether your exception derives from DShopException.
One method, two exits
Every subscribed message — command or event — funnels into TryHandleAsync in BusSubscriber. Here is its core, lightly trimmed:
private async Task<Acknowledgement> TryHandleAsync<TMessage>(TMessage message,
CorrelationContext correlationContext, Func<Task> handle,
Func<TMessage, DShopException, IRejectedEvent> onError = null)
{
var retryPolicy = Policy.Handle<Exception>()
.WaitAndRetryAsync(_retries, i => TimeSpan.FromSeconds(_retryInterval));
return await retryPolicy.ExecuteAsync<Acknowledgement>(async () =>
{
using (var scope = _tracer.BuildSpan("executing-handler")
.AsChildOf(_tracer.ActiveSpan).StartActive(true))
{
var span = scope.Span;
try
{
await handle();
return new Ack();
}
catch (Exception exception)
{
span.SetTag(Tags.Error, true);
if (exception is DShopException dShopException && onError != null)
{
var rejectedEvent = onError(message, dShopException);
await _busClient.PublishAsync(rejectedEvent,
ctx => ctx.UseMessageContext(correlationContext));
span.SetTag("error-type", "domain");
return new Ack();
}
span.SetTag("error-type", "infrastructure");
throw new Exception($"Unable to handle a message: '{message.GetType().Name}'...");
}
}
});
}
Read the catch. There are two exits, and the if on line eleven is the whole taxonomy:
- Domain lane. If the exception is a
DShopExceptionand the subscription supplied anonErrormapper, the handler's failure is a business rule saying “no” — insufficient stock, unknown customer, a discount that doesn't apply. The framework callsonErrorto turn the exception into anIRejectedEvent, publishes that rejection back onto the bus with the same correlation context, tags the spanerror-type: domain, and returnsAck. The message is done. It will not be retried, because retrying a business rejection just rejects it again. - Infrastructure lane. Anything else — a Mongo timeout, a broken socket, a null reference — is treated as the world misbehaving, not the message. The span is tagged
error-type: infrastructureand the method throws, which bubbles up to the Polly policy wrapping the whole thing and triggers a wait-and-retry (_retriestimes, default three,_retryIntervalseconds apart, default two). Transient failures get another go; the message is only lost if the world is still broken three tries later.
The type of the exception is the routing decision. A DShopException means “this will fail identically forever, tell someone”; any other exception means “this might work if I wait.” That is a genuinely good instinct, and it is the same instinct a mature system encodes with far more ceremony.
The rejected event as a return address
The domain lane's output is an IRejectedEvent, a tiny contract:
public interface IRejectedEvent : IEvent
{
string Reason { get; }
string Code { get; }
}
Note it extends IEvent — a rejection is an event, published on the same exchanges as any other, discoverable by anyone who subscribes. When the API gateway sends a command and wants to know if it was rejected, it does not get a synchronous error; it subscribes to the corresponding rejected event and correlates by id. This is the Return Address pattern done by convention: the reply channel is not a field on the message, it is the estate-wide agreement that failures come back as events. A default factory exists for the generic case:
public static IRejectedEvent For(string name)
=> new RejectedEvent($"There was an error when executing: {name}", $"{name}_error");
and services supply richer, specific rejections through the onError delegate on each subscription — SubscribeCommand<ReserveProducts>(onError: (cmd, ex) => new ReserveProductsRejected(...)).
Following a rejection home
It is worth tracing the full round trip once, because the return-address idea only makes sense end to end. Say a customer submits a checkout. The gateway sends a ReserveProducts command and, because it cannot block on an asynchronous result, wires the subscription to name its failure:
// Products service, at subscription time:
subscriber.SubscribeCommand<ReserveProducts>(
onError: (cmd, ex) => new ReserveProductsRejected(cmd.OrderId, ex.Code, ex.Message));
// Products handler, when stock is short:
if (product.Quantity < requested)
throw new InsufficientStockException(product.Id); // a DShopException
InsufficientStockException derives from DShopException, so TryHandleAsync takes the domain lane: it calls onError, gets a ReserveProductsRejected, and publishes it with the same correlation id the command carried. Nobody waits synchronously; instead the interested party subscribes to the rejection and matches on that id:
subscriber.SubscribeEvent<ReserveProductsRejected>(); // handler resolves the pending operation
The reply channel was never a field on any message — it is the estate-wide agreement that a failure comes back as an event on the ordinary exchanges, correlated by id. That is what “Return Address by convention” buys and costs: no request/response plumbing, but also no compiler check that anyone is listening for the rejection you publish.
The sharp edges
Praise earned, here is the honest ledger, because the taxonomy has exactly two failure modes and both are about discipline:
- Miscategorised exceptions ride the wrong lane. The classification is
is DShopException, nothing more. A domain rule that throws a plainInvalidOperationException— easy to do,throw new InvalidOperationException("cart is empty")reads perfectly naturally — is classified as infrastructure and retried three times before failing, when it should have been an immediate rejection. The whole design leans on every domain failure being aDShopExceptionand every infrastructure failure not being one. There is no analyzer enforcing that; it is a convention, like everything else in part three. - No
onErrormeans no rejection. The domain lane requiresonError != null. A subscription registered without an error mapper sends even itsDShopExceptions to the infrastructure lane — retried, then thrown, never published as a rejection. So a service can look like it handles rejections (it throwsDShopException) while emitting none, because whoever wired the subscription forgot the second argument.
There is a clean rule of thumb hiding in here, and it is worth stealing regardless of framework: if waiting might fix it, retry it; if waiting only repeats it, reject it. DShopException versus everything else is that sentence compiled into C#. The taxonomy is good; it is only as strong as the estate's discipline in throwing the right exception type — and that discipline is exactly what the second series puts under load.
The Polly retry in that method is the live retry path. Sitting a few lines below it in the same file is a second, complete retry implementation that no code ever calls — and the comment explaining why is the best documentation in the repository. That is next.