Idempotency
Idempotency makes retryable work safe. Use it when the same logical command may arrive more than once: a user double-submits a form, a mobile client retries a request, a webhook provider redelivers an event, or a job provider retries background work.
Beignet provides the primitive and convention. Your app still decides which operations require idempotency, how keys are scoped, what counts as the same payload, and whether completed results may be replayed.
bun add @beignet/core
Core API
Use runIdempotently(...) inside a use case, job, listener, webhook handler, or
schedule. This keeps retry safety at the workflow layer instead of coupling it
only to HTTP.
import {
createIdempotencyFingerprint,
runIdempotently,
} from "@beignet/core/idempotency";
export const createAppointment = useCase
.command("appointments.create")
.input(CreateAppointmentInput)
.output(AppointmentOutput)
.run(async ({ ctx, input }) => {
const fingerprint = await createIdempotencyFingerprint(input, {
omit: ["idempotencyKey"],
});
return runIdempotently(ctx.ports.idempotency, {
namespace: "appointments.create",
key: input.idempotencyKey,
scope: {
tenantId: ctx.tenant?.id,
actorId: ctx.actor?.id,
},
fingerprint,
ttlSec: 60 * 60 * 24,
run: () =>
ctx.ports.uow.transaction((tx) =>
tx.appointments.create({
patientId: input.patientId,
startsAt: input.startsAt,
reason: input.reason,
}),
),
});
});
The helper reserves the key before running the workflow, completes it with the returned result, and releases the reservation if the workflow throws.
Keys, scopes, and fingerprints
An idempotent operation has three identities:
| Field | Purpose |
|---|---|
namespace | Separates unrelated operations that may receive the same key |
scope | Prevents one actor or tenant from replaying another actor or tenant's result |
fingerprint | Detects when the same key is reused with different payload data |
Use a namespace that matches the command or webhook:
namespace: "appointments.create"
Scope keys to the boundary that owns the operation:
scope: {
tenantId: ctx.tenant?.id,
actorId: ctx.actor?.id,
}
Create a fingerprint from the logical command input, omitting the key itself and other request-only metadata:
const fingerprint = await createIdempotencyFingerprint(input, {
omit: ["idempotencyKey", "requestId"],
});
createIdempotencyFingerprint(...) creates a stable SHA-256 digest from a
canonical JSON representation. It does not store the original payload.
Port wiring
Add an idempotency port to your app ports:
// ports/index.ts
import type { IdempotencyPort } from "@beignet/core/idempotency";
export type AppPorts = {
idempotency: IdempotencyPort;
// other ports...
};
Use the memory adapter only for tests, local examples, and single-process development:
// infra/app-ports.ts
import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
export const appPorts = definePorts<AppPorts>({
idempotency: createMemoryIdempotencyStore(),
// other ports...
});
Production apps should implement IdempotencyPort with a durable store such as
SQL or Redis. The storage operation behind reserve(...) must be atomic.
Durable storage shape
A SQL-backed adapter usually stores one row per reserved key:
create table idempotency_keys (
namespace text not null,
scope_key text not null,
key text not null,
fingerprint text not null,
status text not null,
result_json text,
reserved_at text not null,
completed_at text,
expires_at text,
primary key (namespace, scope_key, key)
);
reserve(...) should atomically insert the row. If the row already exists,
compare the stored fingerprint:
- Same fingerprint and completed: return
replay - Same fingerprint and in progress: return
inProgress - Different fingerprint: return
conflict - Expired row: delete or replace it before reserving
complete(...) stores the result after the protected workflow succeeds.
fail(...) should release an in-progress reservation when the workflow throws.
Unit-of-work-aware SQL adapters
For high-integrity workflows, prefer a SQL adapter that can participate in the same Unit of Work as the business write. That keeps the reservation, domain write, audit entry, domain-event record, and completed idempotency result in one database transaction.
The use case shape changes from "idempotency wraps a transaction" to "the transaction exposes an idempotency port":
await ctx.ports.uow.transaction((tx) =>
runIdempotently(tx.idempotency, {
namespace: "appointments.create",
key: input.idempotencyKey,
scope: {
tenantId: ctx.tenant?.id,
actorId: ctx.actor?.id,
},
fingerprint,
ttlSec: 60 * 60 * 24,
run: async () => {
const appointment = await tx.appointments.create({
patientId: input.patientId,
startsAt: input.startsAt,
reason: input.reason,
});
await tx.audit.record(/* ... */);
await events.record(tx.events, appointmentCreated, {
appointmentId: appointment.id,
});
return appointment;
},
}),
);
The transaction port type should include idempotency next to the repositories
and other transaction-scoped ports:
import type { IdempotencyPort } from "@beignet/core/idempotency";
import type { DomainEventRecorderPort } from "@beignet/core/ports";
type TransactionPorts = {
appointments: AppointmentRepository;
audit: AuditLogPort;
events: DomainEventRecorderPort;
idempotency: IdempotencyPort;
};
Infra creates the idempotency adapter from the transaction client, just like it
creates repositories from that transaction client. In a Drizzle/Turso app, that
can look like this. createAppIdempotencyPort(...) is an app-owned adapter that
implements IdempotencyPort with the transaction client.
import { createDrizzleTursoUnitOfWork } from "@beignet/provider-drizzle-turso";
uow: createDrizzleTursoUnitOfWork({
db,
eventBus,
createTransactionPorts: (tx, events) => ({
appointments: createAppointmentRepository(tx),
audit: createAuditLog(tx),
events,
idempotency: createAppIdempotencyPort(tx),
}),
});
This is stronger than a standalone idempotency store because the database commit becomes the single durability boundary:
- If the transaction commits, the business record and completed idempotency result commit together.
- If the workflow throws, the transaction rolls back and the reservation rolls back with it.
- If the process crashes before commit, the database does not keep a completed idempotency result for work that did not commit.
- If a retry arrives while the first transaction is still running, the unique key or row lock prevents duplicate work.
A transaction-aware SQL adapter should use a unique key on
(namespace, scope_key, key) and perform reservation with database-native
atomicity. In Postgres, for example, insert ... on conflict or row-level locks
can serialize concurrent attempts for the same key. In SQLite, the write
transaction and primary key constraint provide the serialization boundary.
If your UOW publishes events or dispatches jobs after commit, remember that those side effects are outside the rolled-back database transaction. Use an outbox when event or job delivery must be durable. Idempotency prevents duplicate command execution; it does not replace durable message delivery.
Contract metadata
Contracts may document idempotency requirements with metadata:
export const createAppointment = appointments
.post("/")
.headers(
z.object({
"idempotency-key": z.string().min(1),
}),
)
.body(CreateAppointmentRequest)
.responses({ 201: AppointmentResponse })
.meta({
idempotency: {
required: true,
header: "idempotency-key",
scope: "actor-tenant",
ttlSec: 60 * 60 * 24,
},
});
Metadata is useful for docs, OpenAPI, and future HTTP hooks. The use case should
still call runIdempotently(...) for workflows that must stay safe outside
HTTP.
Failure behavior
runIdempotently(...) handles the common cases:
| Reservation | Behavior |
|---|---|
reserved | Run the workflow and store the result |
replay | Return the stored result by default |
inProgress | Throw IdempotencyInProgressError |
conflict | Throw IdempotencyConflictError |
When an idempotent use case is exposed through HTTP, map these primitive errors
to declared app catalog errors so the route returns a route-owned 409
response instead of an unhandled 500.
Pass replay: "error" when an operation should reject duplicates instead of
returning the stored result:
await runIdempotently(ctx.ports.idempotency, {
namespace: "webhooks.stripe",
key: event.id,
fingerprint,
replay: "error",
run: () => processStripeEvent(event),
});
Jobs and webhooks
Jobs and webhooks should use keys from the system that retries them:
await runIdempotently(ctx.ports.idempotency, {
namespace: "webhooks.stripe.invoice-paid",
key: stripeEvent.id,
scope: { provider: "stripe" },
fingerprint: await createIdempotencyFingerprint(stripeEvent.data.object),
ttlSec: 60 * 60 * 24 * 30,
run: () => handleInvoicePaid(stripeEvent),
});
For Beignet jobs, use an app-owned job id, provider event id, or logical command id as the key. Keep the idempotency check inside the job handler when the job itself owns the retried work.
Testing
Use the memory store in tests:
const idempotency = createMemoryIdempotencyStore();
const first = await runIdempotently(idempotency, {
namespace: "posts.create",
key: "key_1",
fingerprint: "fingerprint_1",
run: async () => ({ id: "post_1" }),
});
const second = await runIdempotently(idempotency, {
namespace: "posts.create",
key: "key_1",
fingerprint: "fingerprint_1",
run: async () => ({ id: "post_2" }),
});
expect(second).toEqual(first);
This makes duplicate-submit behavior testable without depending on a database or queue provider.