What choreography and orchestration mean, and where process state lives
In event-driven choreography, no service is in charge. The order service publishes OrderPlaced; the payment service consumes it, charges the card, and publishes PaymentCaptured; inventory reacts to that, and so on. Each service knows only which events it listens for and which it emits. The business process exists only as the sum of these subscriptions.
In orchestration, a coordinator owns the process. It calls payment, waits for the result, calls inventory, and decides what to do on failure. Services expose operations and don't need to know what comes before or after them.
The real difference is where process state lives. In choreography, "where is order 4812?" has no single answer. The state is spread across the payment table, the inventory reservations, and any messages still sitting in a broker queue. To reconstruct it, you correlate logs or query several databases. A workflow engine such as Temporal persists an explicit event history for each workflow execution. That history records which steps ran, what they returned, and what is pending, and the engine can replay it after a crash.
So the choice is about who owns workflow state and failure handling, not about style:
| Dimension | Choreography | Orchestration |
|---|---|---|
| Coupling | Services coupled to event schemas | Coordinator coupled to service APIs |
| State location | Implicit: service databases plus in-flight messages | Explicit: persisted workflow history |
| Flow visibility | Reconstructed from logs and traces | Readable in workflow code and history |
| Central dependency | Message broker | Workflow engine (and its backing store) |
How a durable workflow engine like Temporal works
Temporal splits a workflow into two kinds of code. Workflow code describes the process: call this step, wait for that signal, sleep three days. Activities do the actual side effects, such as charging a card, calling an API or writing to a database. The Temporal service never runs your code. It stores each workflow's event history in its persistence layer and puts tasks on named task queues. Your workers long-poll those queues and run the tasks.
Client ──start/signal──▶ ┌──────────────────────────┐
│ Temporal service │
│ history + timers in DB │
└───────┬───────────┬──────┘
workflow tasks activity tasks
▼ ▼
[task queue: orders] ...
▲ long-poll ▲
Worker A Worker B
When a worker picks up a workflow task, it replays the workflow function from the start against the recorded history. Each completed activity, fired timer or received signal returns its recorded result instead of running again, so the code rebuilds its local state and continues from where it stopped. If a worker crashes, another worker replays the same history and carries on. A three-day sleep is a durable timer stored in the service, so no process sits blocked while it waits. Activities have their own retry policies and timeouts, so a flaky dependency does not require custom retry loops.
Replay only works if workflow code is deterministic. Given the same history, it must issue the same commands every time. That rules out four things in workflow code:
- direct I/O
- reading the wall clock
- randomness
- iterating over unordered collections
The SDKs provide deterministic replacements for time and randomness. Any I/O belongs in activities.
History grows with every event, and replay cost grows with it. The service also enforces limits on history size. For workflows that loop indefinitely or run for months, use continue-as-new. It closes the current run and starts a fresh one with the carried-over state as input, so history stays small.
+--------+ start workflow / send signal
| Client |------------------------------------+
+--------+ |
v
+------------------------------------------------------------------------+
| Temporal service (stores state, never runs your code) |
| |
| +------------------------------------------------------------+ |
| | Persistence: event history per workflow | |
| | durable timers | |
| +------------------------------------------------------------+ |
| |
| task queue "orders" |
| +-------------------------+ +-------------------------+ |
| | workflow tasks | | activity tasks | |
| +-------------------------+ +-------------------------+ |
+-------------+-------------------------------+--------------------------+
^ ^
| long-poll; send commands | long-poll; send result
| |
+-------------+-------------------------------+-------------+
| Worker processes A, B, ... (any one can take any task) |
| +-------------------------+ +-------------------------+ |
| | Workflow code | | Activity code | |
| | replays history to | | side effects (I/O) | |
| | rebuild state; | | retry policy and | |
| | deterministic only | | timeouts | |
| +-------------------------+ +-------------------------+ |
+-----------------------------------------------------------+
The Temporal service persists each workflow's event history and durable timers and dispatches workflow and activity tasks on a task queue, while workers long-poll that queue to replay deterministic workflow code and to run side-effecting activities under retry policies and timeouts.
Implementing the same order saga both ways
Choreography: each service reacts and compensates
The order service writes the order and publishes OrderPlaced. The payment service consumes it, charges the card, and emits PaymentCaptured. Inventory consumes that event, reserves stock, and emits InventoryReserved. Shipping consumes that and tries to book a carrier. If booking fails, it emits ShippingFailed.
The unwinding is the hard part, because no single place owns it. Inventory must subscribe to ShippingFailed and release the reservation. Payment must subscribe to it too and refund. The order service must mark the order failed. Each handler should check a processed-events table keyed by order ID and event type before acting, because brokers redeliver. Each outbound call should carry an idempotency key, such as orderId:refund, so a retried refund cannot pay out twice. The saga's logic exists only as the sum of these subscriptions.
Orchestration: one function owns the sequence
In Temporal, a single workflow function calls capturePayment, reserveInventory and bookShipment as activities, in that order. After each step succeeds, it pushes the matching undo onto a compensation stack: refundPayment, then releaseInventory. When bookShipment fails after its retries run out, the workflow pops the stack and runs the compensations in reverse order.
Each activity receives an idempotency key derived from the workflow ID. Activities can retry, and a retried activity must not repeat a side effect that already took place. The whole saga can be read top to bottom in one file. The engine's durable history ensures the compensations run even if a worker crashes partway through the unwind.
import { proxyActivities, workflowInfo } from '@temporalio/workflow';
import type * as activities from './activities';
// Activities run outside the workflow. The engine retries them per this policy.
const { capturePayment, reserveInventory, bookShipment, refundPayment, releaseInventory } =
proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 5 },
});
export async function orderSaga(orderId: string, amountCents: number): Promise<string> {
// The workflow ID is stable across retries and worker restarts,
// so keys derived from it let downstream services dedupe repeated calls.
const wfId = workflowInfo().workflowId;
const compensations: Array<() => Promise<void>> = [];
try {
await capturePayment(orderId, amountCents, `${wfId}:capture`);
// Register the undo only after the step has succeeded.
compensations.push(() => refundPayment(orderId, amountCents, `${wfId}:refund`));
await reserveInventory(orderId, `${wfId}:reserve`);
compensations.push(() => releaseInventory(orderId, `${wfId}:release`));
// If this still fails after its retries, control jumps to catch.
await bookShipment(orderId, `${wfId}:ship`);
return 'COMPLETED';
} catch (err) {
// Unwind in reverse: release inventory first, then refund payment.
// Durable history lets the unwind resume if a worker crashes midway.
for (const undo of compensations.reverse()) {
await undo();
}
throw err; // Surface the original failure, such as the ShippingFailed cause.
}
}
A Temporal TypeScript workflow runs payment, inventory and shipping in order, pushes an idempotency-keyed compensation after each successful step, and replays them in reverse when shipping fails.
Failure handling, retries and idempotency in each model
In a choreographed saga, failure handling is spread across every consumer. When a handler throws, the broker redelivers the message. A message that keeps failing, such as one with a schema the consumer can't parse, becomes a poison message that blocks its partition or queue until it is moved to a dead-letter queue. That keeps traffic flowing, but the business process is now stalled halfway. Payment may be captured while inventory was never reserved, and no single component knows the saga is stuck. Teams usually end up writing reconciliation jobs that scan for orders stuck in intermediate states and either replay or compensate them.
In an orchestrated workflow, each activity has a declared retry policy covering backoff, maximum attempts and non-retryable error types. Activities also have timeouts. Start-to-close bounds a single attempt, and schedule-to-close bounds the whole activity including all retries. When retries are exhausted, the failure surfaces in the workflow code, where the compensation path is an ordinary catch block.
| Concern | Choreography | Orchestration |
|---|---|---|
| Retry config | Per consumer, per broker | Per activity, in workflow code |
| Stuck processes | Found by reconciliation jobs | Visible as open workflows |
| Compensation | Each service reacts to failure events | Explicit, in one place |
| Delivery guarantee | At least once | At least once |
The last row is the important one. A worker can complete a side effect and crash before acknowledging it, so redelivery happens in both models. Neither model gives you exactly-once business effects. Handlers and activities must be idempotent. The reliable pattern is to write a deduplication key, such as the message or activity ID, in the same database transaction as the business change, and to skip the work if that key already exists.
# Schema (PostgreSQL):
# CREATE TABLE processed_messages (
# consumer text NOT NULL,
# message_id text NOT NULL,
# PRIMARY KEY (consumer, message_id)
# );
class InsufficientStock(Exception):
pass
def reserve_inventory(conn, message_id, order_id, sku, qty):
# conn is a psycopg2 connection. 'with conn' wraps one transaction, so the
# dedup row and the business write commit together or roll back together.
# In an orchestrated workflow, message_id would be workflow ID + activity ID.
with conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO processed_messages (consumer, message_id) "
"VALUES (%s, %s) ON CONFLICT DO NOTHING",
("inventory-service", message_id),
)
if cur.rowcount == 0:
# Key already committed: this is a redelivery. Ack it, don't redo it.
return "duplicate"
cur.execute(
"UPDATE inventory SET reserved = reserved + %s "
"WHERE sku = %s AND available - reserved >= %s",
(qty, sku, qty),
)
if cur.rowcount == 0:
# Raising rolls back the dedup row too, so a retry is not skipped.
raise InsufficientStock(sku)
cur.execute(
"INSERT INTO reservations (order_id, sku, qty) VALUES (%s, %s, %s)",
(order_id, sku, qty),
)
return "reserved"
An idempotent inventory handler that inserts a deduplication key in the same PostgreSQL transaction as the reservation, so a redelivered message or retried activity is acknowledged without reserving stock twice.
Observability and change management over time
Answering "where is order X and why is it stuck?"
In choreography, no component holds the answer. The order's state is spread across the inventory, payment and shipping services, each with its own view. Reconstructing it depends on every producer propagating a correlation ID, every consumer logging and tracing with it, and a tracing backend stitching spans across asynchronous hops. One service that drops the ID breaks the chain. A stuck order usually shows up as an event that was never consumed, and that is hard to see because nothing records a missing event.
A workflow engine stores each workflow's execution history: every activity scheduled, every retry, every signal and timer, keyed by workflow ID. You look up the order and see that it is retrying the payment activity, along with the last error. Cross-service tracing is still useful, but it is no longer the only way to find state.
Changing the process
In choreography, events are public contracts. Other teams consume them, so schemas have to evolve compatibly, with additive fields and no repurposed meanings. Adding a step, such as a fraud check between reservation and payment, means new events, a new consumer, and changes to the services that listen for the old trigger. Those changes ship across several teams in a coordinated order.
In orchestration, adding a step is a change to one codebase. The cost is determinism. Temporal rebuilds in-flight workflows by replaying their history against the current code. If the new code issues commands in a different sequence, replay fails with a non-determinism error and those executions stall. To avoid this, you guard the change with the SDK's versioning or patching mechanism, so that old executions follow the old path and new ones take the new step. You can remove the guard only after every execution started on the old path has completed.
Who should use which model: when to use it and when to avoid it
Pick choreography when a service reacts to a fact and nobody needs to know the overall outcome. Examples include sending a confirmation email, updating a search index, or fanning out a notification to several consumers. Each consumer can fail and retry on its own, and adding a new subscriber touches no existing code. Choreography breaks down when a process has many steps, branches, and compensations. The sequence then exists only as an emergent chain of events. Cycles appear, and answering "what happens after payment fails?" means reading five codebases.
Pick orchestration when the process itself is the product: it runs for a long time, has several steps, includes timers or waits for a human, and must undo earlier steps on failure. Avoid it for one-hop reactions, where it adds a dependency for no benefit. Also avoid a single "god workflow" that calls into other teams' internals. That workflow turns into a deployment bottleneck and couples every team to one owner.
The hybrid default
Publish events at domain boundaries, and orchestrate within one bounded process owned by one team. The order workflow emits OrderPlaced. It does not coordinate shipping's internal steps.
A workflow engine is not the only option for orchestration. A hand-rolled state machine in a database table, paired with a transactional outbox, works well for a few short processes. Other workflow engines with different trade-offs also exist.
| Process characteristic | Recommended approach |
|---|---|
| Single reaction or fan-out notification | Choreography |
| Few steps, no compensation, short-lived | Choreography or DB state machine + outbox |
| Many steps, branching, compensations | Orchestration |
| Timers, deadlines, human approval | Orchestration |
| Spans multiple teams' domains | Events between domains, orchestration within each |
Impact on cost, engineering time and migration
Orchestration's costs are visible up front. Self-hosting Temporal means operating its services plus a persistence store sized for constant history writes. The alternative is paying for a hosted option. Either way, the cluster becomes critical infrastructure. If it is unavailable, workflows stop advancing and timers stop firing, so it needs the on-call coverage and capacity planning you give your primary database. Engineers also need training. Workflow code must be deterministic, changes to running workflows must be versioned, and long-lived workflows need continue-as-new to keep history bounded. Mistakes here show up as replay failures in production.
Choreography's costs are spread across teams and harder to see:
- distributed tracing to reconstruct a single order
- schema governance for shared events
- reconciliation jobs that find processes stuck between events
- engineer hours spent correlating logs across services
Illustrative estimate (assumed numbers, not a benchmark)
Hours saved per month = incidents × (diagnosis hours with events − diagnosis hours with workflow history) × engineers involved
Assume 6 stuck-process incidents per month, 3 hours versus 0.5 hours to diagnose, and 2 engineers per incident: 6 × 2.5 × 2 = 30 engineer-hours per month. Weigh that against platform operation and training time. Similar savings apply when you test a flow in one place or add a step to one workflow instead of several consumers.
Migration rarely happens as a single cutover. In-flight orders must finish on the old path. Starting a workflow and publishing an event without an outbox creates dual-write gaps. Downstream consumers still depend on existing events, so the workflow must keep emitting them. Plan for a hybrid period: new orders run through the workflow while old ones drain through events, until consumers migrate.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to choreography vs orchestration microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.