Deep Dive into the Saga Pattern: Choreography vs Orchestration, and Implementing Compensation Logic with Temporal
Take an e-commerce order processing scenario and the situation becomes clear. Order creation → Payment → Inventory deduction → Shipping reservation — four steps, each executed in a different service. If payment succeeds but inventory deduction fails, how do you reverse the payment that has already gone through?
2PC (Two-Phase Commit) is not a realistic option in distributed environments. It holds locks for extended periods, and if any participating service fails to respond, the entire system blocks. The Saga pattern approaches this problem differently. It breaks a global transaction into multiple per-service local transactions, and if a step fails midway, it executes compensating transactions in reverse order for the steps that already succeeded. The goal is to achieve eventual consistency without global locks.
In this article, we compare the two Saga implementation approaches — Choreography and Orchestration — at the code level, and walk through how to implement compensation logic step by step with the Temporal TypeScript SDK. Rather than reviewing concepts, the focus is on exactly where each approach becomes complex in practice.
Core Concept: What Is a Compensating Transaction?
A Saga is a sequence of distributed local transactions. Each step commits to its own DB, and on failure, compensating transactions that cancel the previous steps execute in reverse order.
A compensating transaction is not a true rollback. It is a new transaction that undoes an already-committed change. The compensation for "deduct inventory" is "restore inventory," and the compensation for "process payment" is "refund." The reason this distinction matters is that the compensation itself can fail. That scenario must be included in your design.
Choreography: Services Connected by Events
How It Works
In Choreography, there is no central coordinator. Each service publishes an event when it completes its local transaction, and the next service subscribes to that event and performs its work. Compensation is also triggered by events.
When inventory deduction fails, the compensation for the preceding steps — payment refund and order cancellation — must also be triggered via an event chain. This means the compensation flow must be designed with the same event-driven structure as the happy path.
Code Example: Kafka-Based Choreography
// order-service/order.consumer.ts
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ brokers: ['localhost:9092'] });
const producer = kafka.producer();
const consumer = kafka.consumer({ groupId: 'order-service' });
export async function createOrderAndPublish(ctx: OrderContext): Promise<void> {
await orderRepository.create({ id: ctx.orderId, status: 'PENDING' });
await producer.send({
topic: 'order-events',
messages: [{ key: ctx.orderId, value: JSON.stringify({ type: 'OrderCreated', ...ctx }) }],
});
}
// Subscribe to compensation event: cancel order after payment refund completes
await consumer.subscribe({ topics: ['payment-events'] });
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value!.toString());
if (event.type === 'PaymentRefunded') {
await orderRepository.updateStatus(event.orderId, 'CANCELLED');
}
},
});// payment-service/payment.consumer.ts
import { Kafka } from 'kafkajs';
const consumer = kafka.consumer({ groupId: 'payment-service' });
const producer = kafka.producer();
// Subscribe to both happy-path events and compensation events
await consumer.subscribe({ topics: ['order-events', 'inventory-events'] });
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value!.toString());
if (event.type === 'OrderCreated') {
const result = await paymentGateway.charge(event);
await producer.send({
topic: 'payment-events',
messages: [{
key: event.orderId,
value: JSON.stringify(
result.success
? { type: 'PaymentProcessed', orderId: event.orderId, transactionId: result.transactionId }
: { type: 'PaymentFailed', orderId: event.orderId, reason: result.reason }
),
}],
});
}
// Refund on inventory failure (compensating transaction)
if (event.type === 'InventoryFailed') {
await paymentGateway.refund({ orderId: event.orderId, transactionId: event.transactionId });
await producer.send({
topic: 'payment-events',
messages: [{
key: event.orderId,
value: JSON.stringify({ type: 'PaymentRefunded', orderId: event.orderId }),
}],
});
}
},
});Each service subscribes to both happy-path events and compensation events. As the number of services grows, so does the number of event cases each consumer must handle. The overall flow is spread across multiple files, so to trace "which events flow in which order," you have to read the consumer code of every service together. This is the point where debugging costs spike if you operate without distributed tracing (Jaeger, OpenTelemetry).
Transactional Outbox: Ensuring Atomicity Between the DB and Kafka
There is a common pitfall when building Choreography: the scenario where "the DB is updated but Kafka message publication fails." This happens because a DB commit and a Kafka publish are not an atomic unit. The Outbox pattern solves this.
// TypeORM + PostgreSQL example
async function createOrderWithOutbox(
ctx: OrderContext,
queryRunner: QueryRunner
): Promise<void> {
await queryRunner.startTransaction();
try {
await queryRunner.manager.save(Order, {
id: ctx.orderId,
status: 'PENDING',
amount: ctx.amount,
});
// Instead of publishing directly to Kafka, write to the outbox table (same transaction)
await queryRunner.manager.save(OutboxEvent, {
id: uuidv4(),
aggregateType: 'Order',
aggregateId: ctx.orderId,
eventType: 'OrderCreated',
payload: JSON.stringify(ctx),
createdAt: new Date(),
});
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
}
// A CDC tool like Debezium detects changes to the outbox table → publishes to Kafka
}Instead of publishing events directly to Kafka, you write them to the outbox table within the same DB transaction. A CDC tool (such as Debezium) detects changes to the outbox table and publishes them to Kafka. Running a Choreography-based Saga without an Outbox will cause event loss in rare timing windows, and reproducing the root cause is difficult.
Orchestration: The Orchestrator Controls the Flow
In Orchestration, the orchestrator issues commands to each service in sequence. On failure, the orchestrator directly executes compensating transactions in reverse order. At each failure point, all compensations for the preceding steps execute in a cascade.
When shipping reservation fails, three steps execute in order: restore inventory → refund payment → cancel order. This entire flow is expressed in one place — the orchestrator code.
Implementing an Orchestration Saga with Temporal
Temporal's Role
Temporal is a Durable Execution platform. It persistently records the entire execution history of a workflow (Activity started, completed, failed, etc.) on the server.
Even if a service restarts or a network failure occurs, the workflow resumes from exactly the point where it failed. This durability is possible because the Temporal server itself is configured as a cluster. The Frontend, History, and Matching services each run as multiple instances, and workflow history is stored in an external DB (Cassandra or PostgreSQL). Even if one Temporal server instance goes down, workflow state is not lost. The single point of failure problem with an orchestrator is mitigated by this architecture.
And standard language constructs like try...catch become the guarantee mechanism for compensation logic. There is no need to design a separate state machine or event handler.
Installing Packages
pnpm add @temporalio/workflow @temporalio/activity @temporalio/worker @temporalio/clientDefining Activities
// activities.ts
import { log, ApplicationFailure } from '@temporalio/activity';
export interface OrderContext {
orderId: string;
userId: string;
amount: number;
items: Array<{ productId: string; quantity: number }>;
}
export async function createOrder(ctx: OrderContext): Promise<void> {
await orderRepository.create({ id: ctx.orderId, userId: ctx.userId, status: 'PENDING', amount: ctx.amount });
log.info('Order created', { orderId: ctx.orderId });
}
export async function cancelOrder(orderId: string): Promise<void> {
// Skip if order is already cancelled (idempotency guarantee)
const order = await orderRepository.findById(orderId);
if (order?.status === 'CANCELLED') return;
await orderRepository.updateStatus(orderId, 'CANCELLED');
log.info('Order cancelled', { orderId });
}
export async function processPayment(ctx: OrderContext): Promise<string> {
const result = await paymentGateway.charge({
orderId: ctx.orderId,
userId: ctx.userId,
amount: ctx.amount,
});
if (!result.success) {
// Payment decline won't change outcome on retry, so mark as non-retryable
throw ApplicationFailure.nonRetryable(`Payment failed: ${result.reason}`, 'PAYMENT_DECLINED');
}
return result.transactionId;
}
export async function refundPayment(orderId: string, transactionId: string): Promise<void> {
const existing = await paymentRepository.findRefund(orderId);
if (existing) {
log.info('Already refunded, skipping', { orderId });
return;
}
await paymentGateway.refund({ orderId, transactionId, idempotencyKey: `refund-${orderId}` });
await paymentRepository.markRefunded(orderId, transactionId);
log.info('Refund complete', { orderId });
}
export async function updateInventory(ctx: OrderContext): Promise<void> {
// Note: if this Activity is retried, already-decremented items may be decremented again.
// In production, use an idempotency key on each decrement call, or
// check current inventory before decrementing to verify it has not already been processed.
const decremented: typeof ctx.items = [];
for (const item of ctx.items) {
const success = await inventoryService.decrement(item.productId, item.quantity);
if (!success) {
// Insufficient inventory — restore already-decremented items within this Activity, then throw non-retryable
for (const done of decremented) {
await inventoryService.increment(done.productId, done.quantity);
}
throw ApplicationFailure.nonRetryable(`Insufficient inventory: ${item.productId}`, 'INSUFFICIENT_INVENTORY');
}
decremented.push(item);
}
}
export async function restoreInventory(ctx: OrderContext): Promise<void> {
for (const item of ctx.items) {
await inventoryService.increment(item.productId, item.quantity);
}
log.info('Inventory restored', { orderId: ctx.orderId });
}
export async function scheduleShipping(orderId: string): Promise<void> {
await shippingService.schedule({ orderId });
}
export async function cancelShipping(orderId: string): Promise<void> {
await shippingService.cancel({ orderId });
log.info('Shipping cancelled', { orderId });
}Implementing the Workflow with the Compensation Stack Pattern
// workflows.ts
import { proxyActivities, log } from '@temporalio/workflow';
import type * as activities from './activities';
import type { OrderContext } from './activities';
const {
createOrder, cancelOrder,
processPayment, refundPayment,
updateInventory, restoreInventory,
scheduleShipping, cancelShipping,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '10 minutes',
retry: {
maximumAttempts: 3,
initialInterval: '1 second',
backoffCoefficient: 2,
},
});
export async function orderSagaWorkflow(ctx: OrderContext): Promise<void> {
// Compensation stack: unshift() prepends to the array so for...of iteration automatically runs in reverse
const compensations: Array<() => Promise<void>> = [];
try {
await createOrder(ctx);
compensations.unshift(() => cancelOrder(ctx.orderId));
const transactionId = await processPayment(ctx);
// transactionId is captured in a closure — no need to manage it as separate state at compensation time
compensations.unshift(() => refundPayment(ctx.orderId, transactionId));
await updateInventory(ctx);
compensations.unshift(() => restoreInventory(ctx));
await scheduleShipping(ctx.orderId);
compensations.unshift(() => cancelShipping(ctx.orderId));
log.info('Order processing complete', { orderId: ctx.orderId });
} catch (err) {
log.error('Order processing failed, executing compensating transactions', { orderId: ctx.orderId, error: String(err) });
for (const compensate of compensations) {
try {
await compensate();
} catch (compensationErr) {
// Compensation failed — the system may be left in an inconsistent state.
// Send an alert or push to a DLQ to secure a manual recovery path.
log.error('Compensating transaction failed', { error: String(compensationErr) });
// Even if this compensation fails, continue attempting the remaining compensations
}
}
throw err;
}
}Worker and Client
// worker.ts
import { Worker } from '@temporalio/worker';
import * as activities from './activities';
async function run() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: 'order-saga',
});
await worker.run();
}
run().catch(console.error);// client.ts
import { Client } from '@temporalio/client';
import { orderSagaWorkflow } from './workflows';
import type { OrderContext } from './activities';
const client = new Client();
const orderCtx: OrderContext = {
orderId: 'ORD-20260712-001',
userId: 'user-123',
amount: 59000,
items: [{ productId: 'PROD-456', quantity: 2 }],
};
const handle = await client.workflow.start(orderSagaWorkflow, {
taskQueue: 'order-saga',
// Use orderId as workflowId: prevents duplicate executions for the same order.
// Even if start() is called twice due to network retries, Temporal guarantees idempotency.
workflowId: `order-${orderCtx.orderId}`,
args: [orderCtx],
});
console.log(`Workflow started: ${handle.workflowId}`);
await handle.result();The key is using orderId as the workflowId. Even if start() is called twice due to network retries, Temporal prevents duplicate execution of a Workflow with the same workflowId. Generating a new value each time with uuidv4() breaks this idempotency.
The Essential Requirement for Compensation Logic: Idempotency
Temporal retries failed Activities. This principle applies to all Activities, not just compensation Activities. If processPayment is retried due to a network error under a maximumAttempts: 3 setting, the payment API could be called twice. Every Activity must be designed so that calling it multiple times with the same input produces the side effect exactly once.
// Idempotency guarantee example — refund Activity
export async function refundPayment(orderId: string, transactionId: string): Promise<void> {
const existing = await paymentRepository.findRefund(orderId);
if (existing) {
log.info('Already refunded, skipping', { orderId });
return; // Called twice, but the refund happens only once
}
await paymentGateway.refund({
orderId,
transactionId,
idempotencyKey: `refund-${orderId}`, // Pass idempotency key to the external payment API as well
});
await paymentRepository.markRefunded(orderId, transactionId);
}Handling compensation failure must also be part of your design. If compensation itself fails, a data inconsistency remains. At minimum, send an alert or push to a Dead Letter Queue to secure a manual recovery path.
One more thing to watch out for: Temporal's workflow.terminate() does not execute finally blocks. If compensation logic lives in finally, it will not run on a forced termination. If forced termination is required, use a Signal to guide the Workflow to exit gracefully from within.
Comparing the Two Approaches
| Criterion | Choreography | Orchestration with Temporal |
|---|---|---|
| Understanding the flow | Must trace through every service's consumer code | Entire flow visible in one place — the orchestrator code |
| Service coupling | Indirect dependency via events | Direct dependency: orchestrator ↔ service |
| Debugging | Distributed tracing essential; event correlation is difficult | Each step's status visible instantly in the Temporal UI |
| Compensation logic location | Distributed across each service | Centralized in the orchestrator |
| Single point of failure | None; message broker redundancy required | Temporal cluster handles high availability |
| Complexity as steps increase | Number of events, consumers, and compensation events grows explosively | Orchestrator code grows linearly |
| Compensation order guarantee | Depends on event chain design | Compensation stack automatically guarantees reverse execution |
| Required infrastructure | Kafka/RabbitMQ + CDC/Outbox + distributed tracing | Temporal Server + DB |
Which Approach to Choose
Choreography is a good fit for teams already invested in event-driven architecture where minimizing coupling between services is the top priority. However, once a Saga spans three or more steps, the complexity of the event chain and compensation design rises quickly. Operating without distributed tracing and the Outbox pattern causes debugging costs to spike when failures occur.
Orchestration is a good fit when the business flow is complex and visibility is critical. Because the workflow code directly expresses the business flow, the scope of changes is clear when logic needs to be updated. With Temporal, workflow execution history is durably stored, making failure reproduction and state debugging straightforward.
Closing Thoughts
The real difficulty with the Saga pattern lies less in implementing compensating transactions themselves than in handling the scenarios where compensation fails and deciding where to resume after a service restart.
Choreography distributes this complexity across multiple services; Orchestration concentrates it in one place. Regardless of which approach you choose, the principles to uphold are the same: implement every Activity idempotently, always design a recovery path for compensation failures, and have a means to observe the entire flow.
References
- Temporal Official: Saga Pattern Made Easy
- Temporal Official: Mastering Saga Patterns in Microservices
- Temporal Official: Compensating Transactions
- Temporal Official Docs: Use Cases & Design Patterns
- microservices.io: Pattern: Saga (Chris Richardson)
- ByteByteGo: Saga Pattern Demystified – Orchestration vs Choreography
- AWS Prescriptive Guidance: Saga Choreography Pattern
- AppScale Blog: Saga + Outbox Durable Distributed Transactions 2026
- DEV.to: Transactions in Microservices Part 3 – SAGA Pattern with Temporal.io
- GitHub: temporal-sa/temporal-order-saga