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:

FieldPurpose
namespaceSeparates unrelated operations that may receive the same key
scopePrevents one actor or tenant from replaying another actor or tenant's result
fingerprintDetects 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:

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:

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:

ReservationBehavior
reservedRun the workflow and store the result
replayReturn the stored result by default
inProgressThrow IdempotencyInProgressError
conflictThrow 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.