In a language with first-class functions, a request can be captured as a closure. When is a full Command object still the better choice?
answer
- closure = one method, opaque, unserializable
- object = undo + describe + merge + type
- serialization forces data, not lambdas
- SAM interface: lambda where enough, class where not
- request-as-data + handler for cross-process
basics
~20 sA closure captures a call to run later, which covers the simple case. Prefer a command object when you need more than one operation on it (undo, describe, merge), or when the request must be serialized, inspected, logged, or persisted — a closure is an opaque blob.
solid answer
~50 sA closure is already a command in the minimal sense: it captures the receiver and arguments and defers execution behind a uniform call interface. For fire-and-forget deferral — callbacks, thread-pool tasks, event handlers — a lambda is the right answer and a class per operation is ceremony. A named command object wins when you need (1) **more than one method** on the request: `undo()`, `canMergeWith()`, `describe()` for the UI label, `validate()`, `estimateCost()`; (2) **introspection** — pattern-matching on the request type, filtering a queue, authorizing by command type; (3) **serialization and persistence** — closures cannot generally be written to disk or sent over the network, while a command with named fields is just data plus a type tag; (4) **identity and equality** — deduplicating or coalescing queued requests; (5) **discoverability** — `TransferFunds` in a stack trace beats `lambda$17`. The pragmatic middle ground is a small data record (the request as plain data) plus a separate handler, which keeps serializability without a class hierarchy.
go deeper
Note that a lambda already defers a call, and that an object is needed when the request must do more than just run — for example support undo.
List the concrete capabilities objects add: multiple methods, type inspection, serialization, equality, readable diagnostics; and say that lambdas are right for simple in-process deferral.
Discuss SAM/functional interfaces as a way to have both, the request-as-data + handler split, and how serialization requirements drive the design.
Frame it as a boundary decision: in-process behavior can be functions, but anything crossing a durability or process boundary must be versioned data with explicit dispatch, plus the governance that follows (schema evolution, authorization by command type, audit).
## The honest starting point When the Gang of Four wrote Command in 1994, mainstream OO languages had no closures, so an object was the only way to package a deferred call. Today, a lambda that captures its receiver and arguments *is* a command in the essential sense: deferred, parameterized, uniform interface. Any answer that ignores this sounds dated. The interesting question is what a closure **cannot** do. ## What a closure cannot do ### 1. Carry more than one operation A closure has exactly one entry point: call it. A request often needs several: - `undo()` — reverse it - `describe()` — a human label for the undo menu ("Undo Insert Table") - `canMergeWith(other)` — coalescing consecutive keystrokes - `validate()` / `authorize()` — check before running - `retryable()` / `maxAttempts` — queue policy You can hack this with a pair or record of closures, but at that point you have built an object with worse tooling. ### 2. Be inspected A closure is opaque: you cannot ask "what kind of request is this?" without extra tagging. Command objects support type-based decisions: - authorize by command type (`DeleteAccount` requires admin) - route by type (which handler, which queue, which shard) - filter/cancel pending work ("drop all queued `Reindex` commands for document 42") - meter and log per command type ### 3. Be serialized This is the decisive one for distributed and persistent systems. You cannot reliably write a closure to a database row, a message queue, or the wire — it captures live references and code identity. A command expressed as named fields plus a type discriminator is straight-line data: JSON, protobuf, a table row. Everything the pattern promises about **durable queues, crash-safe replay, audit logs, and cross-process dispatch** requires the request to be data, not a function pointer. ### 4. Have identity and equality Commands as records support structural equality, which enables deduplication ("this exact request already queued"), idempotency keys, and merging. Two lambdas are never usefully equal. ### 5. Be readable in diagnostics Stack traces, profiler frames, dead-letter queue entries, and metrics dimensions all read much better with `TransferFundsCommand` than with a synthetic lambda name. ## Where closures clearly win - Single-use, single-method, in-process deferral: event handlers, `submit(task)`, `setTimeout`, retry wrappers, resource-scoped callbacks. - Reducing a boilerplate hierarchy where each concrete command's `execute()` is one line and nothing else is needed. - Ad-hoc composition and higher-order combinators (wrapping a task with timing, logging, retry) — trivial with functions, verbose with class hierarchies. Many languages let you have both: define the interface with a single abstract method so a lambda satisfies it (functional interfaces / SAM conversion), and write a named class only for the commands that need undo, serialization, or metadata. This is the mature answer: **the interface is the contract; a lambda is an implementation shortcut.** ## The modern middle ground: request-as-data + handler Rather than `execute()` living on the command, split them: ``` data TransferFunds { fromId, toId, amount, idempotencyKey } handler(cmd: TransferFunds, ctx) { ...domain logic... } ``` The command is a pure serializable value with no dependencies; the handler is resolved by type at dispatch time (a command bus). Benefits: commands cross process boundaries and persist trivially, handlers get dependencies injected without polluting the message, and testing the handler needs no wiring. This is the shape used by CQRS frameworks, actor message protocols, and job queues. The trade-off is losing the tight `execute()` cohesion and needing a dispatch/registry mechanism. ## Choosing, in one line If the request only has to *run*, use a closure. If the request also has to be **described, reversed, stored, routed, deduplicated, or authorized**, make it an object — and if it must leave the process, make it plain data with a separate handler.
- You start with lambdas and later need durable retry across process restarts. What has to change?The request must become serializable data: a named type with named fields plus a discriminator, persisted to a queue or table, with the executing logic moved into a handler resolved by type. You also inherit schema-versioning concerns, since old serialized commands must still be readable by new code.
- Does splitting the command from its handler violate the original pattern?It shifts where the logic lives but keeps the intent: the request is still reified as an object and the invoker still knows nothing about the receiver. The difference is that dispatch happens via a registry/bus keyed by command type instead of via virtual dispatch on execute(). It is the standard adaptation when commands must be serialized or handled out of process.
- How do you keep a command-object hierarchy from exploding into hundreds of tiny classes?Use lambdas or a single generic command for trivial one-line operations, group closely related operations into one parameterized command, and reserve dedicated classes for those that genuinely need undo, metadata, serialization, or authorization rules.
saying these in an interview costs you the question
- Claiming closures fully replace the Command pattern — they cannot be serialized, inspected by type, or carry undo/describe/merge.
- Claiming closures are never appropriate and every deferred call needs a class — that is ceremony for fire-and-forget tasks.
- Trying to persist a lambda into a job table or message queue.
- Injecting live service references into a command that must be serialized, instead of resolving them in the handler at execution time.
- Forgetting that once commands are persisted, their schema needs versioning.