Backend · Reliability

The Bug Came Back,
Wearing Five Different Masks

Event dispatch code that has only ever had one handler per event has not shown it can run two. When an event gets its second subscriber, the bug that surfaces can look completely different from one runtime to the next. Four production features made every event with a live handler need a second one, in five language implementations of the same design. All five broke, including two that a test built for exactly this had passed clean a week earlier, each in a different way, ranked here from loudest to quietest.

The code is my example project, which implements the same backend design (DDD, CQRS, Outbox) in five languages side by side. 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 anywhere in the code. Four features meant every one of the five languages had to support two or three simultaneous subscribers to the same event for the first time in shipped code.

This already happened once

A deliberately designed task, part of an experiment that gives AI coding agents the same task in every language, had found 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. What follows 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 broken without a sign, 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 keeps only the last entry written. No crash, no log line, no signal of any kind that a handler had been dropped. That makes it arguably the most dangerous of the five variants, precisely because nothing about it announces itself. This is the gap the earlier experiment 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, a quiet overwrite, no error anywhere. Fixed the same way, with dict[str, list[EventHandlerFn]].

Go had never supported more than one handler per event at the type level at all. map[string]outbox.Handler is strictly one-to-one. In that earlier experiment, this was worked around rather than fixed, with a second call added inline where a proper 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[]>), so 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 prevented every handler registered after it from ever running at all, and nothing reported it. 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 did once two 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 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," but "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 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 different email (the anomaly alert and the withdrawal-completion notice), and the second one got deduped against the first without any error. 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 ever run with two.

Further reading

event-handler-registry.ts (the fixed dispatch loop, every handler run regardless of earlier failures, in backend-service-playbook, my example project that implements the same backend design in five languages side by side) · The Bug That Needed Two Subscribers to Exist (the deliberately harder task that found this the first time, and why easy tasks could not)