Broadcast Without a Filter
Two events in GroupFlights have more than one subscriber, and both are where it breaks - two handlers ask "is this mine?" and two do not, in both directions.
A publish-subscribe channel makes one promise and hides one obligation. The promise is that you can add a subscriber without touching the publisher. The obligation is that every subscriber now receives everything — including the messages that were never about it — and must decide, itself, whether to act. Enterprise Integration Patterns calls the discipline a Selective Consumer, and it is the least glamorous line of code in any handler: the guard at the top that says this one is not mine.
Part 12 showed the estate solving correlation three different ways because the event envelope had nowhere to put it. This part is what happens next, when a correlation lookup returns nothing and nobody planned for it.
Two channels, four handlers
Recall the publish/consume matrix from part 8. Eleven of thirteen integration events have exactly one consumer. Two do not:
| Event | Publisher | Subscribers |
|---|---|---|
PaymentCompleted |
Finance | Sales, Postsale |
DeadlineOverdueIntegrationEvent |
TimeManagement | Sales, Postsale |
Those two are the only genuine broadcast channels in the estate, and they exist for a good structural reason: Finance and TimeManagement are Open Host Services in ADR 03's terms, so they publish for a population of consumers rather than for one named caller. Neither of them knows or should know that Sales and Postsale both listen.
Which means all four handlers face the same question on every message. Both modules request payments; both request deadlines; every completion and every overdue reaches both. Two of the four handlers ask whether the message is theirs. Two do not.
The two that ask
Sales' payment handler, in full at the top:
var paymentRegistryEntry = await _paymentRegistry.GetByPaymentId(@event.PaymentId, cancellationToken);
if (paymentRegistryEntry is null)
{
return; //Payment z innego miejsca w systemie
}
Sales.Application/EventHandlers/External/PaymentCompletedEventHandler.cs:27-32, GroupFlights at commit a19b337. The comment translates as “payment from another place in the system”. Postsale's payment handler carries the identical guard and the identical comment at PaymentCompletedEventHandler.cs:24-27, differing only in that it queries the aggregate rather than a registry.
That is exactly right, and the comment is the part I like most: somebody understood the semantics of a broadcast channel and wrote down why the null is not an error. A null from a correlation lookup on a shared channel means “not addressed to me”, and returning is the correct response. Two developers, or one developer twice, got this right in two modules.
The two that do not
Now Postsale's deadline handler, which is the whole class:
public async Task HandleAsync(DeadlineOverdueIntegrationEvent @event, CancellationToken cancellationToken = default)
{
var changeRequest = await _repository.GetByDeadlineId(@event.Id.Value, cancellationToken);
changeRequest.OnChangePayed();
await _repository.Update(changeRequest, cancellationToken);
}
Postsale.Application/EventHandlers/External/DeadlineOverdueIntegrationEventHandler.cs:19-26. Three statements, and there is no guard between the first and the second. GetByDeadlineId is SingleOrDefaultAsync — part 12 has it in full — so it returns null for any deadline that is not a Postsale change-payment deadline. Every Sales-originated deadline overdue throws a NullReferenceException in Postsale.
Sales' deadline handler has the same shape from the other side:
var deadlineMapping = await _deadlineRegistry.GetByDeadlineId(@event.Id.Value, cancellationToken);
switch (deadlineMapping.SourceType)
Sales.Application/EventHandlers/External/DeadlineOverdueIntegrationEventHandler.cs:35-37. Here the registry throws DoesNotExistException on a miss rather than returning null — the persisted registry from part 12 fails loudly — so Sales does not NRE, it throws a domain exception instead. Different exception, same outcome: a Postsale deadline overdue blows up in Sales' handler, and a Sales deadline overdue blows up in Postsale's. The two modules fail on each other's messages in both directions, on every overdue.
The symmetry with the payment channel is what makes this worth an article. Same estate, same dispatcher, same broadcast semantics, same two modules — guarded on one channel, unguarded on the other. Nothing distinguishes the two cases except that somebody thought about it once and not twice.
What the failure actually costs
The exception does not reach a user. DoWork on the dispatcher's timer collects handler tasks and awaits them together:
var dispatchTasks = new List<Task>();
while (_eventsToDispatch.TryDequeue(out var @event))
{
dispatchTasks.Add(DispatchAsync(@event));
}
await Task.WhenAll(dispatchTasks);
Shared.Plumbing/Events/EventDispatcher.cs:38-43, inside a try whose catch logs and moves on. Three consequences, in ascending severity.
- A log line, five seconds after the fact, on a background thread. Nobody is watching, no HTTP request failed, no test covers it.
Task.WhenAllsurfaces only the first exception. Every event dequeued in that tick is dispatched into the same task list. One handler's NRE aborts theawait, so the results of the sibling handlers for that batch go unobserved. A legitimate deadline for the other module can be collateral damage of a foreign one.- The aggregate is not saved. The throw happens before
_repository.Update, so the state transition the event should have caused never lands. The reservation stays waiting for a deadline that has already passed, and the only trace is a log line nobody read.
To be fair to the estate: this is at-most-once delivery with no dead-letter channel by design, and the README says the dispatcher is in-memory and deliberate. A lost event on a broadcast channel is a consequence of a declared architecture, not a surprise. What is not declared is that two of four subscribers never learned they were on a shared channel.
The second defect in the same three lines
There is a worse problem hiding under the missing guard, and it is a semantic one.
Look again at what Postsale's handler does when the deadline is one of its own. The event is DeadlineOverdue — the client did not pay in time. The handler calls OnChangePayed(). Reading the aggregate confirms what that method means:
public void OnChangePayed()
{
EnsureStillActive();
var paymentDeadlineFulfilled = _paymentRequiredToApplyChange.Deadline with { Fulfilled = true };
_paymentRequiredToApplyChange = _paymentRequiredToApplyChange with { Deadline = paymentDeadlineFulfilled };
// ... computes the deadline shifts and the change to apply ...
_domainEvents.Enqueue(new ReservationChangeAccepted(/* ... */));
}
Postsale.Domain/Changes/Request/ReservationChangeRequest.cs:89-123. It marks the payment deadline fulfilled and raises ReservationChangeAccepted. A missed payment deadline accepts the change. The client who did not pay gets their itinerary changed.
And thirty lines further down the same file:
public void OnPaymentOverdue()
{
_isActive = false;
_completionStatus = CompletionStatus.ChangeRejectedOnPaymentOverdue;
_domainEvents.Enqueue(new ReservationChangeRequestFinalized(Id, _completionStatus.Value));
}
ReservationChangeRequest.cs:126-131. The correct method exists. It deactivates the request and finalises it as rejected on payment overdue. A repository-wide search for OnPaymentOverdue returns exactly one hit: this declaration. It has zero callers.
The domain model got this right. The application layer wired the wrong method to the wrong event. And the third thing wrong in those three lines is that the handler never publishes the domain events it caused — compare its sibling, PaymentCompletedEventHandler.cs:33-35, which ends with PublishMultiple(changeRequest.DomainEvents…). Even when the overdue handler does the wrong thing, the ReservationChangeAccepted it raises never leaves the aggregate, so the deadline corrections and the ISalesApi.ChangeReservation call that should follow never happen.
This is the defect I keep coming back to, because it is one file, twenty-six lines, and it contains three independent bugs — a missing selective-consumer guard, a wrong method call, and a missing publish — while the correct implementation of all three exists in the sibling file next to it. Recall from part 10 that ADR 04's step 7 says the trigger should be marking the payment deadline as fulfilled. Postsale is subscribed to the wrong event entirely, and the ADR would have told anyone who checked.
The rule
Two rules, and they are both one line.
On a shared channel, the first statement of every handler is the filter. If your correlation lookup can return null, the very next line is the guard, and it carries a comment saying why null is not an error. Sales and Postsale wrote that comment twice; the discipline is to write it four times.
Where a lookup returning null is always a bug, do not return null. Sales' persisted registry throws DoesNotExistException on a miss, which is right for a mapping that must exist — but on a broadcast channel a miss is routine, and the two cases need different methods. TryGetByDeadlineId for the filter, GetByDeadlineId for the invariant.
The version of the handler that gets all of it right is not longer than the one that does not:
// Illustrative - fresh code, not the estate's
public async Task HandleAsync(DeadlineOverdueIntegrationEvent @event, CancellationToken ct = default)
{
var changeRequest = await _repository.TryGetByDeadlineId(@event.Id.Value, ct);
if (changeRequest is null)
{
return; // deadline belongs to another module on this shared channel
}
changeRequest.OnPaymentOverdue();
await _repository.Update(changeRequest, ct);
await _eventDispatcher.PublishMultiple(
changeRequest.DomainEvents.Select(e => e.RemapToPublicEvent()).SelectMany(e => e), ct);
}
Four extra lines. The correct method name, the guard, and the publish that makes the aggregate's decision visible to the rest of the system.
Next, drift you can read in a single line: the feature flag that is a constant.