skip to content

Reifying a request as a command object enables queueing, scheduling, retrying and logging. What properties must those command objects have to be safely queued and replayed?

level: seniorimportance: should knowfreq 38%

answer

  1. capture user/time/IDs at creation
  2. data only — no live handles
  3. at-least-once → idempotency key
  4. absolute writes, not increments
  5. version schema; per-key order; dead-letter

basics

~20 s

The command must be self-contained and serializable — all inputs captured, no live references — and its effect must be safe to apply more than once, because queues normally deliver at-least-once. It also needs a version so old stored commands still parse.

solid answer

~50 s

Once a command leaves the caller's stack and lands in a queue or log, four properties matter. **Self-containment**: every input is captured at creation, including anything time- or context-dependent (the acting user, the timestamp, generated IDs), because the context is gone when it runs. **Serializability**: named fields plus a type discriminator; no live service handles, connections, or closures — dependencies get resolved by the handler at execution time. **Idempotency**: durable queues and retries deliver at-least-once, so replay must not double-apply — carry a stable idempotency key and have the handler deduplicate, or make the effect naturally idempotent (set-to-value rather than increment). **Versioning**: a command persisted today may be executed by tomorrow's code, so the schema must evolve additively and old versions must remain readable. On top of that: separate *submitted* from *executed* status, decide ordering guarantees explicitly (per-key ordering vs global), and route permanently failing commands to a dead-letter store rather than retrying forever.

code

json · 12 lines
json
{
  "type": "TransferFunds",
  "version": 2,
  "idempotencyKey": "9f1c-4c2a-...",   // stable across retries
  "requestedBy": "user-1042",           // captured, not re-derived
  "requestedAt": "2026-08-02T10:15:00Z",
  "fromAccount": "ACC-1",
  "toAccount": "ACC-2",
  "amountMinor": 25000,
  "currency": "EUR",
  "expectedFromVersion": 7              // reject stale/reordered replays
}

go deeper

for a junior

Say the command must carry all the data it needs and be storable as plain data, and that it may run more than once so re-running it should not double-apply.

for a middle

Name the four properties — self-contained, serializable, idempotent, versioned — and give the concrete tricks: capture user/time, generate ids up front, dedupe on an idempotency key.

for a senior

Add at-least-once semantics and why exactly-once is not free, transactional dedup, ordering by partition key, optimistic version checks, retry classification, dead-lettering, and lease/visibility timeouts for stuck work.

for a principal

Treat the command schema as a long-lived public contract: evolution policy and upcasting, the queue as a trust boundary with authorization captured at enqueue time, outbox for external side effects, and the API-shape consequence (202 + job status) plus operational observability.

## What changes when the command leaves the stack An in-process command executes microseconds after creation, in the same address space, with the same clock, the same logged-in user, the same open transaction. A queued or logged command may execute: - minutes or days later, - on a different machine, - after a process restart, - **more than once**, - possibly out of order relative to its siblings, - against code that has since been deployed and changed. Every property below follows from that list. ## 1. Self-containment (capture the context at creation) Anything ambient must be materialized into fields: - **Identity**: who requested it (`requestedBy`), and what they were authorized to do. Do not re-derive it at execution — there is no session then. Authorization decisions are usually made at *enqueue* time and recorded; re-checking at execution is a second, deliberate policy choice. - **Time**: `requestedAt` as data. A command that calls "now()" inside execute produces a different result on replay. - **Randomness / generated identifiers**: generate the new entity's ID at creation and store it, so a retry writes the *same* ID instead of creating a duplicate row. This single trick converts many non-idempotent creates into idempotent ones. - **Snapshot vs reference for inputs**: decide deliberately. Storing `productId` means the command executes against the *current* price; storing `priceAtOrderTime` means it executes against the price the user saw. Both are valid; silently picking one is the bug. ## 2. Serializability The command becomes a message: named primitive fields + a type discriminator, encoded as JSON/protobuf/Avro or a table row. Consequences: - **No live dependencies inside the command.** Repositories, HTTP clients, connections, and closures cannot be serialized. This forces the *request-as-data + handler* split: the command carries only data; the handler (resolved by command type) owns the collaborators. This is exactly how command buses, job frameworks, and actor systems work. - **Keep payloads small.** Store references to large blobs, not the blobs. ## 3. Idempotency — the hardest one Durable messaging is **at-least-once** by default: exactly-once end-to-end delivery is not achievable in general, because the acknowledgement can be lost after the effect was applied. So plan for duplicates: - **Idempotency key**: a stable identifier generated once at command creation (not at retry). The handler records processed keys in the same transaction as the effect and skips duplicates. Storing the key with the effect atomically is what makes this correct; a separate "seen" cache updated after the effect leaves a crash window. - **Naturally idempotent effects**: prefer absolute over relative operations — `setStatus(SHIPPED)` replays safely, `incrementBalance(10)` does not. Conditional writes ("update where version = 7", compare-and-set) achieve the same effect. - **Effects outside your database** — sending mail, calling a payment provider — need the provider's own idempotency key, or an outbox pattern where the intent is committed transactionally and dispatched afterwards with dedup. ## 4. Versioning and schema evolution A persisted command is a long-lived contract with your future self. Rules: add fields with defaults, never repurpose or remove a field's meaning, carry an explicit `version`/schema id, and keep upcasting logic that can read old payloads. For replayable logs (undo history that survives restarts, event-sourced streams, crash-recovery journals), old commands may be re-read years later. Deleting a command class without a migration path bricks the log. ## 5. Ordering Stacking commands in a queue does not preserve order once you add concurrency and retries. Decide explicitly: - **Global ordering**: single consumer, no parallelism — simple, slow. - **Per-key ordering**: partition by aggregate/entity id so all commands for one entity run in order while different entities run in parallel. This is the usual answer. - **No ordering**: only acceptable if commands are commutative. Also beware retries reordering: a failed command retried after a later one already succeeded will apply stale intent. Optimistic-concurrency version checks ("apply only if the target is still at version N") reject such stale commands. ## 6. Lifecycle, failure, and observability - **Status separated from submission**: pending / in-flight / succeeded / failed, plus attempt count. "Accepted" is not "done" — the API contract usually becomes 202 + a job id the caller polls. - **Retry policy**: exponential backoff with jitter; distinguish *transient* failures (retry) from *permanent* ones (invalid data — never retryable, don't burn attempts). - **Dead-letter store**: after N attempts, park the command with its error for inspection rather than looping forever or dropping it silently. - **Poison messages and stuck jobs**: a visibility timeout / lease with automatic reclaim, so a worker that dies mid-command doesn't strand it forever. - **Correlation**: carry a trace/correlation id so the deferred execution can be tied back to the originating request in logs. ## 7. Authorization Because the command is now data on a queue, whoever can write to the queue can request the effect. Validate and authorize **before** enqueueing, record the decision in the command, and treat the queue itself as a trust boundary (a worker should not blindly trust a payload from an untrusted producer). ## The takeaway Queueing is not a free consequence of "the request is an object". It is a consequence of the request being **self-contained, serializable, replay-safe, and versioned data**. Command gives you the shape; those four properties give you the correctness.

  • Why can't the queue just guarantee exactly-once delivery and remove the need for idempotency?
    Because the acknowledgement path can fail after the effect has been applied: the worker commits the change, then crashes before acking, so the message is redelivered. End-to-end exactly-once requires the effect and the acknowledgement to be atomic, which across two systems means either a distributed transaction or — the practical answer — an idempotency key deduplicated in the same transaction as the effect.
  • Your command creates a new order row. How do you stop a retry from creating two orders?
    Generate the order id when the command is created and store it in the payload, then insert with that id under a unique constraint (or upsert). The retry attempts the same primary key and is a no-op. Equivalently, dedupe on an idempotency key persisted atomically with the insert.
  • How do you handle a command whose class was renamed or whose fields changed since it was persisted?
    Keep an explicit type discriminator and version in the payload, retain an upcaster that converts old versions into the current shape at read time, and never remove or repurpose existing fields — only add optional ones with defaults.
  • How do you keep ordering when the same entity has several queued commands?
    Partition by entity key so all commands for one entity are handled by a single consumer in sequence, and add optimistic version checks in the payload so a stale command that arrives late is rejected rather than applied.

A mailed work order versus shouting an instruction across the room. The mailed order has to name the customer, the date, the job number and the exact quantities, because nobody who reads it can ask you what you meant — and if the postal service delivers a duplicate copy, the shop must recognize the job number and not build the part twice.

saying these in an interview costs you the question

  • Putting repository/service/connection references inside a command that will be serialized.
  • Calling now() or the current-user lookup inside execute() instead of capturing them when the command was created.
  • Assuming a durable queue gives exactly-once delivery, so no idempotency is needed.
  • Generating the new entity's id inside the handler, so every retry creates another row.
  • Using relative effects (increment/append) where absolute or conditional writes would replay safely.
  • Retrying permanent validation failures with the same backoff as transient ones, instead of failing fast to a dead-letter store.
  • Deleting or renaming persisted command fields with no upcasting path.
  • Assuming queue order is execution order once multiple workers and retries are involved.

context