The tempting design is direct: a charger sends StopTransaction, the handler computes the price, charges the card, updates the session, notifies the driver and returns. It works for fifty chargers and becomes the reason for every outage after that.
What is wrong with it
- The payment gateway is now on the critical path of a WebSocket handler. When it is slow, chargers time out.
- A failure anywhere in the chain leaves the session half-processed, with no defined state and no way to resume.
- Every new consumer of session data — analytics, settlements, a partner webhook — is another thing that can break the protocol handler.
- The gateway cannot be scaled or deployed independently of the billing logic, because they are the same process.
The boundary that fixes it
The gateway does three things and no more: validate the message against the schema, normalise it into an internal vocabulary, and emit an event. It does not write to the business database, it does not price anything and it does not authorise anything beyond asking. Its latency budget is then bounded by the protocol, not by whatever the slowest downstream dependency happens to be today.
Everything else consumes those events: a billing consumer, a settlement consumer, an analytics consumer, a notification consumer. Each can fail, retry and be redeployed without the chargers noticing.
What it demands in return
Idempotency, everywhere. At-least-once delivery means a consumer will eventually see the same event twice, and a billing consumer that is not idempotent will eventually charge somebody twice. Every consumer needs a deterministic key and a check.
| Concern | Requirement |
|---|---|
| Duplicate delivery | Idempotency key per event, checked before side effects |
| Ordering | Per-charger ordering, not global — sessions on one unit must not interleave |
| Poison messages | A dead-letter path, or one bad event blocks a partition forever |
| Unacknowledged work | Consumers reclaim pending messages on startup, or work is silently lost on a crash |
| Slow consumers | A timeout around processing, so one stuck handler cannot deadlock the group |
The last two rows are the ones that get discovered in production. A consumer that crashes with messages in flight and never reclaims them loses sessions quietly. A consumer that blocks forever on an exhausted connection pool stops processing without ever reporting an error.
It is not a message queue question
Redis streams, Kafka, NATS or RabbitMQ will all carry this. The choice affects operational effort and retention, not correctness. The decision that matters is the boundary — that the protocol handler emits and nothing else — and it is worth making on day one, because retrofitting it means rewriting every handler you have.