Interview Practice

Chapter 5 of 5

Events, messaging and idempotent consumers

Publish and consume events without losing, duplicating or reordering business effects.
Read aloud is unavailable in this browser. Reading preferences still work.

The dual-write problem

Saving an order and then sending a Kafka message are two separate writes. If the database commits and the send fails, downstream services never learn about the order; if the send succeeds and the transaction rolls back, they act on an order that does not exist. No try/catch fixes this, because the process can crash between the two operations. The transactional outbox pattern writes the event into an outbox table inside the same database transaction, and a relay publishes committed rows afterwards.

In-process events and transaction phases

Spring's ApplicationEventPublisher delivers events synchronously to listeners in the same thread by default. @TransactionalEventListener defers delivery to a transaction phase, AFTER_COMMIT by default, so listeners never see rolled-back data. Work done in an AFTER_COMMIT listener is outside the original transaction; if it needs its own database writes it requires a new transaction, and failures there do not undo the committed business change. In-process events are lost on a crash, which is why durable effects belong in the outbox.

At-least-once delivery needs idempotent consumers

Brokers and outbox relays typically guarantee at-least-once delivery: a message can arrive twice after a retry, rebalance or relay restart. Consumers must make the effect idempotent, usually by recording processed message IDs under a unique constraint in the same transaction as the business change, or by using naturally idempotent operations such as upserts keyed by a business ID. Ordering is usually guaranteed only per partition key, so choose the key that groups events which must stay ordered.

Worked Java example

Illustrative snippet: imports, surrounding types or collaborators may be omitted. Read the assumptions before adapting it.

@Service
class PaymentEventsConsumer {
    private final ProcessedMessageRepository processed;
    private final LedgerService ledger;

    PaymentEventsConsumer(ProcessedMessageRepository processed, LedgerService ledger) {
        this.processed = processed;
        this.ledger = ledger;
    }

    @KafkaListener(topics = "payments")
    @Transactional
    public void onPayment(PaymentCaptured event) {
        // Unique key on message_id: a duplicate insert fails and rolls back the whole effect.
        if (!processed.tryInsert(event.messageId())) return;
        ledger.credit(event.accountId(), event.amountCents());
    }
}

Walkthrough

tryInsert performs INSERT ... ON CONFLICT DO NOTHING (or catches the unique violation) and reports whether a row was written. The insert and the ledger credit commit together, so a redelivered message finds its ID already present and returns without crediting twice. If the credit throws, both writes roll back and redelivery retries cleanly. Offsets are committed after the listener returns, so a crash before commit causes redelivery, never loss.

Practice it yourself

The ledger lives in another service reached over HTTP rather than in the same database. Explain why the processed-message table alone no longer guarantees exactly-once effects, and what you would change.

Check your reasoning

The local insert and the remote call no longer share a transaction: the remote credit can succeed and the local commit fail, or the reverse, so a retry may double-credit. Pass the message ID as an idempotency key to the ledger API so the ledger deduplicates on its side, and treat timeouts as unknown outcomes that are safe to retry with the same key. The local table then becomes an optimization rather than the guarantee.

Recall checklist

  • Explain the main invariant or contract without looking.
  • Trace the example and test an edge case.
  • State time, space, input variables and assumptions where relevant.
  • Explain one alternative and when you would choose it.

Read further