8. At-Least-Once Publication via the Transactional Outbox

Why core services publish events through a transactional outbox and a per-service outbox-service, giving at-least-once, in-order delivery.
  • Status: accepted
  • Date: 2025-07-03
  • Deciders: Jannik Alexander, Stefan Bente, Daniel Köllgen (governance board)

Context

A core service changes its database and publishes events about the change to Kafka — two independent systems. If the service crashes between the two writes, database and published events disagree: an event is lost, or an event is published for a change that was rolled back. Players and other services build their state from these events (ADR-0006), so events must not be lost, and events for the same partition must arrive in order.

On 2025-01-30 the board judged this of little practical relevance for the MSD and decided to ignore it for the first version. It was implemented nevertheless as the outbox-lib and outbox-service, presented to the board on 2025-07-03. This ADR records that decision and replaces the earlier one. The options are described in Atomic Event Publication.

Decision

  • Core services MUST publish their events through the outbox-lib: events are written to an outbox table in the same local transaction as the domain changes.
  • Each core service MUST be paired with its own outbox-service sidecar, which polls the outbox per configured topic, publishes the events to Kafka in order per partition, and deletes them afterwards.
  • The delivery guarantee is at-least-once, in-order per partition. If the outbox-service crashes after publishing but before deleting, events are published again. Consumers MUST therefore be idempotent (ADR-0007).
  • The outbox-lib assigns the partition and a strictly increasing sequence_id per partition when the event is written, so consumers can detect duplicates.
  • A partition MUST be read by exactly one outbox-service instance. There is no leader election between instances; more than one reader causes race conditions.
  • Players are not provided with an outbox. They MAY implement one.

Alternatives

  • Ignore the problem (publish directly after committing) — the original decision of 2025-01-30; rejected because crashes between commit and publish lose events or publish events for rolled-back changes.
  • Global transactions with a transaction manager — rejected because, although it gives exactly-once delivery, it is complex, often requires commercial products, and reduces response time and throughput of both systems.

Consequences

  • Good: no event is lost, and no event is published for a rolled-back change.
  • Good: order per partition is preserved end to end.
  • Good: the pattern is shared as a library and a service, so core services only write to the outbox.
  • Bad: duplicates are possible, so every consumer needs duplicate detection.
  • Bad: an additional service per core service to deploy and operate; publishing is delayed by the polling interval.
Last modified October 22, 2026: Update documentation (f481966)