Idempotency
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:
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.
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:
| Tier | Import from | Dedup key | Handler scope | Covered on |
|---|---|---|---|---|
| Event | @heximon/events/idempotency | event.eventId (UUIDv7, per-occurrence) | Serial EventHandler dispatch only — the parallel-absorb NotificationHandler bucket bypasses the interceptor chain by design | Events |
| Command | @heximon/cqrs/idempotency | Idempotency-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 unconditionally | CQRS |
| Queue | @heximon/queue/idempotency | Envelope id (stable UUIDv7 across redeliveries) | Queue channel dispatch | Queue & 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
QueueIdempotencyInterceptorguards. - Events — the serial
EventHandlerdispatch theEventIdempotencyInterceptorguards. - CQRS — the
CommandBusdispatch theCommandIdempotencyInterceptorguards, and where theIdempotency-Keyheader flows from. - Key-value storage — the
StorageDI 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.
Notifications
Multi-channel notifications behind one Message / MessageTransport contract — email (Resend, unemail), SMS (Twilio), push (OneSignal, FCM, Web Push), TemplateRenderer, SuppressionService, and the NotificationDispatcher.
Security Hardening
Body-size guard (content-length, 413 RFC-9457), SecurityHeaders (nosniff/DENY/no-referrer/HSTS), RateLimitStore port, MemoryRateLimitStore, RateLimitMiddleware, @heximon/http/security, bearer-only CSRF posture.