Stage 7 · Master
Event-Driven Communication
Producing and Consuming Events Idempotently
A design specification for Meridian's future Kafka consumers: accept at-least-once delivery as normal, give every event durable identity, and make replay produce no duplicate side effect.
Define the Delivery Guarantee First
Event systems are easier to reason about once the delivery guarantee is stated honestly. At-most-once means a message may be lost but will not be retried. At-least-once means a message will be retried when necessary, so duplicates are possible. Exactly-once is often misunderstood: even if a broker offers strong guarantees within its own log, the moment a consumer updates a database, sends an email, or calls another API, the end-to-end system still has to deal with retries and replay. For that reason, production backends usually choose at-least-once delivery and make consumers idempotent.
| Guarantee | What it really means | Why Meridian should choose or reject it |
|---|---|---|
| At-most-once | A failure can drop work permanently | Rejected because silent loss is unacceptable for reporting and audit-oriented consumers |
| Exactly-once everywhere | Sounds clean, but rarely survives external side effects | Rejected as an end-to-end claim because consumers still touch databases and external systems |
| At-least-once plus idempotent consumers | Replays can happen, but duplicate side effects are prevented | Chosen because it is realistic across broker, database, and service boundaries |
Idempotency does not try to prevent brokers from retrying. It ensures that replaying the same event does not create another projection row, another email, or another invoice mutation.
Producer Identity Makes Idempotency Possible
A consumer can only deduplicate an event that has stable identity. That means the producer must emit an event_id, identify which aggregate changed, include the tenant boundary explicitly, and attach an ordering signal such as an aggregate version when consumers care about stale-vs-newer updates. Without those fields, the consumer is left guessing whether two messages describe the same fact or merely look similar.
Consumer Deduplication Must Be Durable
In-memory deduplication is not real deduplication because it disappears on restart—the exact moment replay becomes most likely. A correct consumer records handled event IDs in durable storage that lives next to the projection or side effect it is protecting. In Meridian's planned architecture, the reporting-service should maintain a processed_events table keyed by consumer name and event ID so a partition replay after a crash becomes a no-op instead of a duplicate analytics row.
CREATE TABLE processed_events (
consumer_name text NOT NULL,
tenant_id uuid NOT NULL,
event_id uuid NOT NULL,
aggregate_type text NOT NULL,
aggregate_id uuid NOT NULL,
aggregate_version bigint NOT NULL,
processed_at timestamptz NOT NULL DEFAULT NOW(),
PRIMARY KEY (consumer_name, event_id)
);
CREATE INDEX idx_processed_events_tenant_processed_at
ON processed_events (tenant_id, processed_at DESC);The primary key enforces deduplication. The tenant-aware index supports operations: when one HOA reports missing or duplicated reporting data, the consumer's own database can answer what it has already accepted for that tenant.
apps/reporting-service/internal/consumers/consumer.gocreateResponsibility: Own the Kafka read loop, decode shared envelopes, and dispatch by event type to projection handlers.
Why now: Broker mechanics should live in one place so projection handlers can focus on domain meaning and idempotent writes.
Connects to: Will consume the shared contract introduced under libs/platform/events.
apps/reporting-service/internal/repository/processed_events_repository.gocreateResponsibility: Persist and query processed event markers inside the reporting-service database.
Why now: Deduplication is stateful. A repository makes that state explicit and testable instead of hiding it inside handlers.
Connects to: Backs every reporting projection that must survive replay safely.
apps/reporting-service/internal/projections/payments_projection.gocreateResponsibility: Apply events such as invoice.issued and invoice.paid to reporting tables using durable deduplication.
Why now: A concrete projection makes the idempotency rule practical rather than purely theoretical.
Connects to: Consumes events from billing-service while respecting tenant_id and aggregate ordering.
libs/platform/events/envelope.gomodifyResponsibility: Add the identity and ordering fields consumer idempotency depends on, such as event_id and aggregate_version.
Why now: Consumers cannot deduplicate or reject stale updates if producers emit anonymous payloads.
Connects to: Shared by all three transactional producers and the future reporting consumer.
The Consumer Transaction Boundary
Idempotency depends on ordering the consumer's own steps correctly. The safe sequence is: begin a database transaction, try to insert the processed-event marker, apply the projection or side effect only if that insert succeeds, commit the database transaction, and only then commit the broker offset. If the process crashes after the database commit but before the offset commit, Kafka may redeliver the message, but the consumer's database already knows the event was handled.
Ordering, Retries, and Poison Messages
Idempotency does not remove operational failure modes; it makes them survivable. Temporary failures should be retried with backoff. Malformed events or schema mismatches should be dead-lettered instead of blocking a partition forever. Ordering-sensitive consumers must compare aggregate versions so that an older replay cannot overwrite a newer projection. If a consumer later triggers an external side effect—such as exporting a report—the same event ID should become the idempotency key for that external system whenever possible.
- Duplicate event ID: treat as success and advance after the no-op path completes.
- Older aggregate version than the projection already stores: ignore the stale event without corrupting the read model.
- Temporary downstream outage: retry with bounded backoff instead of committing the offset immediately.
- Malformed payload: move the message to a dead-letter path and alert, because infinite redelivery cannot repair bad data.
The Decision
Meridian's future event consumers should assume at-least-once delivery and be engineered so replay is harmless. That requires stable producer identity, durable processed_events state inside the consumer's own database, transactional dedup-plus-projection writes, and version guards for ordering-sensitive read models. None of this is implemented in meridian yet; the lesson defines the contract that the planned reporting-service and any later consumers must satisfy if they are to remain correct under retries and recovery.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.