noddde

Idempotent Commands

Preventing duplicate command execution with commandId and the IdempotencyStore.

In distributed systems, commands may be delivered more than once — network retries, at-least-once messaging, or user double-clicks can all produce duplicates. Idempotent command processing ensures that a command with a given commandId is executed at most once: the first dispatch processes normally, and subsequent dispatches with the same commandId are silently skipped.

Quick Example

// First dispatch — processes normally, events persisted and published
await domain.dispatchCommand({
  name: "Transfer",
  targetAggregateId: "acc-123",
  payload: { amount: 100, toAccountId: "acc-456" },
  commandId: "transfer-req-789",
});

// Second dispatch with the same commandId — silently skipped, no side effects
await domain.dispatchCommand({
  name: "Transfer",
  targetAggregateId: "acc-123",
  payload: { amount: 100, toAccountId: "acc-456" },
  commandId: "transfer-req-789",
});

Commands without a commandId bypass idempotency entirely and are always processed. This makes the feature fully opt-in and backward compatible.

How It Works

The idempotency check is the very first thing that happens when a command enters the aggregate lifecycle — before loading state, before acquiring locks, before any work at all:

Command received
  |
  |  commandId present AND IdempotencyStore configured?
  |    yes → exists(commandId)?
  |             yes → return immediately (no-op)
  |             no  → proceed with normal lifecycle
  |    no  → proceed with normal lifecycle
  v
Load state → Execute handler → Apply events → Persist → Publish

After successful execution, the commandId is recorded in the same unit of work as the events. This ensures atomicity:

  • If event persistence succeeds, the idempotency record is saved — future duplicates will be skipped.
  • If event persistence fails, the idempotency record is not saved — the command can be safely retried.

The commandId Field

The commandId is an optional field on the base Command interface:

interface Command {
  name: string;
  payload?: any;
  commandId?: ID; // string | number | bigint
}

Since AggregateCommand extends Command, the field is available on all aggregate commands. The commandId should be a unique value generated by the caller — typically a UUID, a request ID from an API gateway, or a message ID from a queue.

When using DefineCommands to build command unions, the commandId field is not part of the generated types (it is a framework concern, not a domain modeling concern). Pass it at dispatch time:

// Inline — TypeScript infers the full type including commandId
await domain.dispatchCommand({
  name: "CreateAccount",
  targetAggregateId: "acc-1",
  commandId: "req-abc-123",
});

// Or via spread
const cmd = createAccountCommand("acc-1");
await domain.dispatchCommand({ ...cmd, commandId: requestId });

Configuring the IdempotencyStore

Add an idempotencyStore factory to the infrastructure section of your domain configuration:

import { configureDomain, InMemoryIdempotencyStore } from "@noddde/engine";

const domain = await configureDomain<MyInfrastructure>({
  writeModel: { aggregates: { BankAccount } },
  readModel: { projections: {} },
  infrastructure: {
    idempotencyStore: () => new InMemoryIdempotencyStore(),
    // ...other providers
  },
});

Without an idempotencyStore configured, the commandId field is ignored and all commands are processed normally.

TTL and Cleanup

The InMemoryIdempotencyStore accepts an optional ttlMs parameter. When set, exists() performs lazy cleanup: if a record has expired, it is deleted and the command is treated as new.

// Records auto-expire after 24 hours on exists() checks
const store = new InMemoryIdempotencyStore(24 * 60 * 60 * 1000);

For explicit batch cleanup (e.g., on a timer), call removeExpired():

// Remove all records older than 1 hour
await store.removeExpired(60 * 60 * 1000);

Without a ttlMs, records persist indefinitely until explicitly removed.

The IdempotencyStore Interface

interface IdempotencyRecord {
  commandId: ID;
  aggregateName: string;
  aggregateId: ID;
  processedAt: string; // ISO 8601
}

interface IdempotencyStore {
  exists(commandId: ID): Promise<boolean>;
  save(record: IdempotencyRecord): Promise<void>;
  remove(commandId: ID): Promise<void>;
  removeExpired(ttlMs: number): Promise<void>;
}

The interface is deliberately minimal. Implement it for production backends (PostgreSQL, Redis, DynamoDB, etc.) to make idempotency durable across process restarts.

Interaction with Concurrency Strategies

The idempotency check runs before the concurrency strategy:

  • Optimistic: the check happens once, before any retry loop. If the command was already processed, no retries are attempted.
  • Pessimistic: the check happens before lock acquisition. Duplicate commands do not acquire a lock.

This means duplicate commands are the cheapest possible operation — a single exists() call, no state loading, no lock contention.

Interaction with Unit of Work

Inside an explicit withUnitOfWork(), the idempotency check still happens per-command. Each command with a commandId is checked individually. The idempotency records for all commands in the unit of work are enlisted in the same UoW, so they commit or roll back atomically with the events.

When to Use Idempotent Commands

ScenarioRecommendation
API endpoints with retry logicAlways — use the request ID as commandId
Message queue consumers (at-least-once)Always — use the message ID as commandId
User-facing forms (double-click)Recommended — generate a commandId on form submission
Internal saga commandsUsually unnecessary — sagas have their own state tracking
One-off scriptsOptional — depends on whether re-runs are expected

Next Steps

On this page