7. Idempotent Consumers Remember Processed Events

Why consumers of the core services detect duplicates by storing what they have processed, instead of relying on idempotent business logic.
  • Status: accepted
  • Date: 2025-01-30
  • Deciders: Jannik Alexander, Stefan Bente, Daniel Köllgen (governance board)

Context

Events in the MSD are delivered at least once: the outbox may publish an event twice (ADR-0008), and Kafka redelivers a batch when processing is interrupted. Players may also send the same intent twice. Processing a duplicate must not change the outcome — a robot must not move two fields for one move intent.

The solution must be correct for relative state changes (moving a robot, deducting credits), work the same way in every core service, and avoid expensive broker transactions. The options are described in Idempotent Event Consumer.

Decision

  • Core services MUST detect duplicates by storing what they have processed, in the same database transaction as the domain changes the event causes.
  • Duplicates are identified by one of the standard headers (ADR-0005):
    • event_id — store the ids of processed events and look them up before processing; or
    • sequence_id — store the highest sequence id processed per topic and partition (a “high watermark”) and skip anything at or below it. Cheaper, but only for events from the core services.
  • Duplicate detection for player intents MUST use event_id, as a sequence_id set by players cannot be trusted.
  • Services MUST take particular care with transaction boundaries (e.g. @Transactional), so that the stored marker and the domain change commit or roll back together.

Alternatives

  • Idempotent business logic — rejected because relative state changes such as moving robots cannot easily be made idempotent without remodelling them (e.g. as event-sourced transactions).
  • Transactional event processing (Kafka transactions) — rejected because transactions are costly, add latency and do not include the service’s database.

Consequences

  • Good: works for any business logic, including relative changes.
  • Good: the check can move into a shared library (the consumer-lib) once it is proven.
  • Bad: every consumer needs an additional table and a lookup per event.
  • Bad: relies on one consumer per partition; without it, the lookup needs locking.
Last modified October 22, 2026: Update documentation (f481966)