Backend · Reliability
The Bug Came Back,
Wearing Five Different Masks
A week after a benchmark task found that two of five languages crashed or silently dropped a handler the first time an event needed two subscribers, four real production features made every event with a live handler need a second one. This time all five languages broke — including the two that had passed clean before — each in a genuinely different way, ranked here from loudest to quietest.
Four features shipped across all five languages that week: a spending forecast built from trailing months of history, a merchant-name transaction categorizer, a withdrawal anomaly alert that only ever notifies and never blocks, and a refund-reason classifier feeding an analytics endpoint. Every one of them, on the Account Bounded Context, needed to react to MoneyWithdrawn — an event that, until that week, had never had more than one handler in the entire repository. Four features meant every one of the five languages had to actually support two or three simultaneous subscribers to the same event for the first time in real, shipped code.
A deliberately designed benchmark task had found exactly this failure a week earlier — two Bounded Contexts subscribing to the same event exposed that Java and FastAPI's handler maps couldn't hold more than one, and Kotlin's fix at the time was flagged as a workaround, not a real one. Go and NestJS were declared clean. This round is what happened when the same shape of requirement hit all five languages again, for real, and none of them turned out to be as clean as believed.
Ranked From Loudest to Quietest
Java's handler map was built with Collectors.toMap(...) — registering a second handler for an already-used event type throws IllegalStateException: Duplicate key, and it throws at application startup, the instant the second handler bean registers. That's the worst-sounding failure mode and, in one sense, the safest — nothing ships silently broken, the app simply refuses to boot until it's fixed. A new OutboxEventDispatcher built on Collectors.groupingBy replaced it.
Kotlin's routing table was a plain mapOf(...) literal. A duplicate key in a Kotlin map literal doesn't throw and doesn't warn — it silently keeps only the last entry written. No crash, no log line, no signal of any kind that a handler had been dropped; arguably the most dangerous of the five variants precisely because nothing about it announces itself. This is the exact gap the earlier benchmark round had flagged and explicitly left unfixed. It's fixed now, restructured to Map<String, List<...>> via groupBy.
FastAPI's build_event_handlers() had the identical shape and the identical failure — a plain dict literal, silent overwrite, no error anywhere. Fixed the same way: dict[str, list[EventHandlerFn]].
Go had never supported more than one handler per event at the type level at all — map[string]outbox.Handler, strictly one-to-one. In the benchmark round, this was worked around rather than fixed: a second call added inline where a real second registration should have gone. This time it got the real fix — map[string][]outbox.Handler and a runHandlers function.
NestJS was the most interesting, because it was the one everyone had reason to trust. Its registry was already correctly shaped — Map<string, EventHandlerFn[]> — holding multiple handlers was never in question. What broke was the dispatch loop itself: it iterated handlers for an event type and stopped at the first one that threw, so a failing first handler silently prevented every handler registered after it from ever running at all. The type system said this was fine. Nothing about the type system could have caught it, because the bug wasn't in what the structure could hold — it was in what the loop actually did once two real handlers existed to iterate over:
public async handle(eventType: string, payload: object): Promise<void> {
const errors: unknown[] = []
for (const handler of this.handlers.get(eventType) ?? []) {
try {
await handler(payload)
} catch (error) {
this.logger.error({ message: 'A handler failed for eventType', event_type: eventType, error })
errors.push(error)
}
}
if (errors.length > 0) throw errors[0]
}Every handler now runs regardless of an earlier one's failure, each failure gets its own log line, and the message only throws — leaving it unacknowledged for redelivery — after every handler has had its turn.
What Actually Caught All Five
Every one of these was caught the same way: the end-to-end test written for the new feature also asserted that the pre-existing handler for the same event still ran. Not just "does my new handler fire" — "does the old one still fire too, now that it has company." That's the specific, repeatable discipline this generalizes into: adding a second subscriber to any event that already has one means the test suite's job is no longer just verifying the new path works, it's verifying the new path didn't silently break the old one.
A Second, Smaller Version of the Same Root Cause
One more bug came from the identical situation — two handlers legitimately reacting to the same event for the first time. The SES notification idempotency ledger, in both Kotlin and FastAPI, deduplicated by the event's ID alone, an assumption that one Outbox delivery produces at most one email. It broke the moment two handlers on the same MoneyWithdrawn event each needed to send a genuinely different email — the anomaly alert and the withdrawal-completion notice — and the second one got silently deduped against the first. Fixed by widening the dedup key from the event ID alone to the pair of event ID and event type.
Five languages, five different failure shapes, one shared cause: a capability every implementation assumed it had, that had simply never been asked for before. "Already handles this correctly" turned out to be a claim about code nobody had actually run with two.
event-handler-registry.ts — the fixed dispatch loop, every handler run regardless of earlier failures · The Bug That Needed Two Subscribers to Exist — the benchmark round that found this the first time