Heximon Logo
Capabilities

Idempotency

Idempotency tiers for event, queue, and command dispatch — EventIdempotencyInterceptor, CommandIdempotencyInterceptor, QueueIdempotencyInterceptor, Idempotency-Key header, ContextData.idempotencyKey, Storage port, best-effort dedup.

Replaying a message that already succeeded should be a no-op, not a duplicate side effect. Heximon provides opt-in idempotency guards across three dispatch tiers — domain events, queue channels, and HTTP commands — so you can re-deliver safely under at-least-once delivery without writing the guard yourself.

All three tiers share the same contract: no key means no dedup; entering the gate claims the key (dropping a duplicate that is already claimed or processed), success confirms it, and a throwing handler releases its claim so the retry runs. There is no @heximon/idempotency package — each interceptor ships as an ./idempotency subpath of the tier package it guards, so you import it from a package you already depend on, and a tier you don't use never pulls the guard in. Every shipped interceptor extends its interceptor base directly, so it's directly listable under the owning module's namespace key — no app-authored wrapper is needed.

Worked example — the queue tier

The queue tier has the clearest dedup key: every queued message carries a stable envelope id — a UUIDv7 the producer mints once and that survives redelivery. QueueIdempotencyInterceptor (@heximon/queue/idempotency) reads that id, checks it against a Storage marker, and skips the handler when it's already been seen:

src/app.module.ts
import { Module } from "@heximon/runtime";
import { Storage } from "@heximon/kv";
import { MemoryStorage } from "@heximon/kv/memory";
import { QueueIdempotencyInterceptor } from "@heximon/queue/idempotency";

export class AppModule extends Module({
  providers: [
    QueueIdempotencyInterceptor,                          // its ctor injects Context + Storage
    { provide: Storage, useClass: MemoryStorage },        // a strongly consistent Storage in production
  ],
  queue: { interceptors: [QueueIdempotencyInterceptor] }, // listed directly — no wrapper
}) {}

The interceptor injects the Storage DI token from @heximon/kv (an optional peer, pulled in only when you use the ./idempotency subpath) — bind MemoryStorage for dev, a strongly consistent networked backend in production. The guard is a claim / confirm / release protocol: entering the gate claims the key with setIfAbsent under a bounded TTL (a redelivery of a claimed or processed id is dropped), success overwrites the claim with a processed marker, and a throwing handler releases its claim so the next redelivery retries immediately — a crash instead unblocks when the claim TTL lapses. Both windows are tunable via the optional trailing constructor options: claimTtlSeconds (default 300 — keep it comfortably above your slowest handler) and retentionTtlSeconds (how long the processed marker keeps deduping; omitted = indefinitely). The stored key is prefixed idempotency:queue:<id> so it never collides with the event or command tiers sharing the same Storage.

For cross-service fan-out, where subscribing services may share one networked store, the queue key gains namespace segments so copies never collide: a per-subscriber segment (idempotency:queue:<subscriberService>:<id>), and — when the event arrives over a native broadcast (a shared stream several producers write to) — a per-producer source segment (idempotency:queue:<subscriberService>:<source>:<id>). A single-service app keeps the flat idempotency:queue:<id> key.

The guard is exactly as strong as your backend's setIfAbsent. On a backend with an atomic conditional write — Redis via @heximon/kv/redis (Node-only), Upstash via @heximon/kv/upstash (edge-safe), or the Durable-Object-backed KvRoomStorage from @heximon/durable/kv on Cloudflare — truly-concurrent deliveries of the same id admit exactly one handler run. On a check-then-set backend the claim degrades to best-effort, and MemoryStorage is per-isolate — on Workers with multiple isolates, one isolate's claim is invisible to another.Never Cloudflare Workers KV for this guard. It's eventually consistent (up to ~60 s global propagation), so a stale read across regions can let a retried request slip straight past the dedup check it exists to enforce — see Key/Value storage for the same warning on the adapter itself.Mind the claim TTL. A handler that outlives its claim (claimTtlSeconds, default 300) lets a concurrent redelivery re-claim the id mid-run — size it comfortably above your slowest handler. Set retentionTtlSeconds to keep the processed-marker set from growing unbounded (each marker then expires with its dedup window).

The other two tiers

Same contract, same ./idempotency subpath pattern, different dedup key:

TierImport fromDedup keyHandler scopeCovered on
Event@heximon/events/idempotencyevent.eventId (UUIDv7, per-occurrence)Serial EventHandler dispatch only — the parallel-absorb NotificationHandler bucket bypasses the interceptor chain by designEvents
Command@heximon/cqrs/idempotencyIdempotency-Key HTTP header → context.get("idempotencyKey")HTTP-dispatched commands only; a command dispatched without the header (or outside an HTTP context) carries no key and falls through unconditionallyCQRS
Queue@heximon/queue/idempotencyEnvelope id (stable UUIDv7 across redeliveries)Queue channel dispatchQueue & Integration Events

Wiring either follows the same shape as the queue example above — swap the import, the interceptor class, and the namespace key (events: { interceptors: [...] } / cqrs: { commandInterceptors: [...] }). All three tiers can share one Storage instance; each uses a distinct key prefix (idempotency:event:<eventId>, idempotency:command:<key>, idempotency:queue:<id>) so the keys never collide.

See also

  • Queue & Integration Events — the queue tier and channel dispatch the QueueIdempotencyInterceptor guards.
  • Events — the serial EventHandler dispatch the EventIdempotencyInterceptor guards.
  • CQRS — the CommandBus dispatch the CommandIdempotencyInterceptor guards, and where the Idempotency-Key header flows from.
  • Key-value storage — the Storage DI token every interceptor injects, the first-class Redis adapters, and why Cloudflare Workers KV is the wrong backend for a dedup guard.
  • Reliable Integration Events — the transactional outbox pattern that makes integration events durable before dedup matters.
  • Reactive Choreography — multi-step compensation over the durable outbox, with idempotency guards at each hop.
  • the gap's reliable cross-service example — the durable outbox that makes integration events safe to redeliver, where idempotency guards become necessary.
  • the gap's workflow compensation example — multi-hop choreography with per-handler idempotency checks on each step.
Copyright © 2026