Special projects

EventRelay

A payment and its event are stored in one transaction and published to Kafka through an outbox. The consumer remembers processed events, so a repeated delivery changes nothing.

Type
Payment service
Stack
Java · PostgreSQL · Kafka
Code
Open on GitHub
Year
2026

A mail room that sends once

The payment and its event envelope drop into the box in one move. A carriage sends the envelope down a tube, and the receiver keeps a spike of numbers: a repeated envelope falls into the “seen” tray.

DEAD
The envelope in flight: the part to watchThe outbox drawer: payment and envelope land in one transactionThe consumer's spike: a known number goes to the “seen” trayA model of EventRelay, a payment service in Java with PostgreSQL and Kafka. The payment and its outbox row commit to the database in one transaction, so the drawer takes both sheets in one move. A carriage claims rows and sends the envelope down the tube (Kafka, acks=all). If the process stops after Kafka has acknowledged the event but before the database records it, the same envelope goes out again: delivery is at least once, which is why a click here drops the carriage right after the send. A failure makes the row wait longer each time, and after the eighth attempt it gets the DEAD state and goes to an operator. The consumer records the event id together with its projection update: a known number falls into the “seen” tray, a new one is spiked and moves the counting frame. On the bench there is one publisher instead of several, and the pauses between attempts are squeezed from seconds and minutes to fractions of a second.

The problem

A process can stop after Kafka has acknowledged an event but before the database records it as published. Then the event goes out again. So delivery is at least once, and a repeat must be harmless.

The solution

The payment and its outbox row commit to PostgreSQL in one transaction. Several publishers claim rows with FOR UPDATE SKIP LOCKED and send to Kafka with acks=all; a failure retries with a backoff from one second to five minutes, and after eight attempts the row goes to an operator. The consumer records the event id together with its projection update, so a repeated delivery changes nothing.

Under the hood

  • Transactional outbox: the payment and event in one transaction.
  • Parallel publishers that never claim the same row.
  • Exponential retries and a DEAD state after eight attempts.
  • An idempotent consumer: processed events stored with the projection.
  • Malformed records go to a dead-letter topic after three tries.
Code on GitHub

Shall we talk about your project?

Discuss your project