Skip to content

Command operations

A booking command often needs to decide which seat to reserve and then call a reservation service. Mixing those responsibilities makes a decision test depend on network substitutes, and adding a second external call soon introduces a hand-written cleanup stack.

Command operations separate the decision from the work. Your Handle() returns immutable descriptions of what should happen. Arc executes them, handles failures, and invokes an optional business reversal when the command’s commit boundary permits recovery.

Command operations are the recommended way to express immediate, inline side effects in model-bound commands. Direct service calls remain supported; operations are useful when you want an inspectable decision, consistent execution, and framework-managed compensation. They require neither Chronicle nor a separate operation-handler class.

An operation implements ICommandOperation. Its public Execute() method performs the work; its optional Compensate() method describes the reversal. Both receive services through method-parameter injection.

Your command constructs the operation without invoking either method. Calling Handle() directly therefore lets a spec inspect the proposed work without reserving a seat. Calling the command through Arc executes the operation. CommandScenario follows the same production pipeline; it does not silently replace execution with a recording stub.

This extends the separation already offered by Provide():

Provide: acquire

Handle: decide

Execute operations

Complete scopes

Return result

The decision is pure only when it has no I/O, clock reads, randomness, or mutable external state. Returning an operation does not make an otherwise impure handler pure. Supply the identity and state the decision needs rather than creating hidden dependencies inside Handle().

Continue with implementing an operation for a complete reservation example. If you already have a service-backed handler, migrate its inline work while preserving the command’s input and caller response.

You write the reversal, not the error handling

Section titled “You write the reversal, not the error handling”

For a reservation, the reversal is canceling the reservation owned by that request. Arc takes care of tracking started operations, stopping after a failure, selecting recovery, and running compensators in reverse order.

You do not need a try/catch, a rollback stack, a success flag, or a switch identifying which operation failed. If an operation does not have a useful reversal, omit Compensate(); Arc does not invent one or report that it reversed the work.

Consider four returned operations. A and B complete, C enters Execute() and throws, and D never starts. When compensation is permitted, Arc attempts C, then B, then A. It never compensates D.

Why include C? A remote service may have accepted C’s request before the connection failed. A thrown exception proves that the local invocation failed—not that the external system did nothing.

The execution reference describes ordering, cancellation, and the optional failure context without making those concerns mandatory application boilerplate.

A compensating action is new work. Releasing a reservation does not erase the fact that it was reserved; refunding money does not mean a payment never happened.

With Chronicle, returned events are enrolled before operations execute, and the event transaction completes afterward. A known commit rejection can permit compensation. A successful commit followed by an unrelated cleanup failure must not automatically cancel reservations associated with already-committed business facts.

An unknown commit outcome is different again. A timeout may mean that the store committed but its acknowledgment was lost. Arc does not infer “nothing committed” from a failed CommandResult, and it does not automatically retry or reverse operations while commitment is uncertain.

Compensation is best-effort and in-process. It is not a durable queue, a distributed transaction, or recovery after a process crash. Read the commit and recovery contract before relying on reversal.

Your needPrefer
Acquire data for a decisionProvide() or a directly supplied decision input
Reject invalid input or a business decisionA validator or a recognized ValidationResult alternative
Return information to the callerAn ordinary command response
Perform immediate external work chosen by a commandA command operation
Reverse an attempted operation when the command fails safely before commitmentIts optional Compensate() method
Record a durable domain fact with ChronicleReturn an event
React reliably to an already-committed factA Chronicle reactor or an appropriate durable workflow/outbox
Obtain an external call’s result to make the decision itselfAn explicit service interaction; a post-decision operation cannot supply that earlier input
Extend the framework’s interpretation of specialized return typesA response value handler

Do not replace durable email delivery, payment workflows, or retriable webhooks with inline operations merely to shorten a handler. Choose the delivery and reconciliation guarantees first. Reactor-returned commands are a separate composition mechanism; a reactor retry can execute its command operations again.

Three boundaries answer three different questions:

  1. Decision: call Handle() and assert the returned operation data and order.
  2. Operation adapter: call Execute() or Compensate() with a substitute and verify the provider request.
  3. Arc composition: use CommandScenario with controlled dependencies to prove execution, failure, compensation, and the resulting command outcome.

None proves that a production reservation provider has correct idempotency or cancellation semantics. Test that provider at its own integration boundary.

Follow testing command operations to turn this separation into executable specifications.