До сих пор мы рассматривали микросервисы как изолированные «клетки» организма: каждая имеет свой ограниченный контекст, свою базу данных и свою бизнес-логику. Но реальный бизнес не живёт внутри границ одного сервиса. Оформление заказа, бронирование путешествия или одобрение кредита — это сквозные бизнес-процессы, которые пересекают границы множества микросервисов.
Как организовать взаимодействие между этими независимыми акторами, чтобы гарантировать целостность бизнес-операции? В архитектуре EDM используются два фундаментальных паттерна — хореография и оркестровка. А когда речь заходит о согласованности данных, на сцену выходит паттерн Saga.
Это восьмая глава цикла. Она опирается на главу III (контракты событий), главу IV (Transactional Outbox) и главу VII (state stores). Здесь мы разберём, как эти механизмы складываются в сквозные бизнес-процессы.
В хореографической архитектуре нет центрального управляющего органа. Каждый микросервис самостоятельно «слушает» потоки событий и реагирует на них, публикуя свои собственные события в ответ. Это напоминает танцоров на вечеринке: каждый знает свои шаги и движется в ритм общей музыки, но дирижёра нет.
Сервис А завершает свою часть работы и публикует OrderCreated. Сервис Б, отвечающий за резервирование товаров, подписан на этот поток. Получив событие, он резервирует товар и публикует InventoryReserved. Сервис В (списание средств) подхватывает событие — и так далее.
Сервис А не знает о существовании сервисов Б и В. Он просто бросает факт в «эфир». Новые сервисы могут появиться, просто начав подписываться на нужные потоки, без изменения кода источника.
// Order Service
KStream<String, Order> orders = builder.stream("orders");
orders
.filter((k, o) -> o.getStatus() == CREATED)
.mapValues(o -> new InventoryReserveCommand(o.getOrderId(), o.getItems()))
.to("inventory-commands");
// Inventory Service
KStream<String, InventoryReserveCommand> cmds = builder.stream("inventory-commands");
KStream<String, InventoryReserved>[] branches = cmds
.mapValues(this::tryReserve)
.branch(
(k, r) -> r.isSuccess(),
(k, r) -> !r.isSuccess()
);
branches[0].to("inventory-events"); // success
branches[1].to("inventory-failures"); // triggers compensation
Здесь нет оркестратора. Каждый сервис сам решает, как реагировать на успех или неудачу.
Оркестрация предполагает наличие центрального координатора. Он содержит в себе всю логику рабочего процесса, отдаёт команды подчинённым микросервисам (через события или прямые вызовы) и ждёт от них ответов, чтобы принять решение о следующем шаге.
Оркестратор получает запрос на создание заказа. Он отправляет команду сервису инвентаря: «Зарезервируй товар». Получив подтверждение, он отправляет команду платёжному сервису: «Спиши средства». Если на любом этапе происходит сбой, оркестратор инициирует откат уже выполненных действий.
Оркестратор хранит состояние саги — текущий шаг, историю, результат каждого шага. Это позволяет знать, где остановился процесс, принимать решение о компенсации и восстанавливаться после сбоя.
| Критерий | Хореография | Оркестровка |
|---|---|---|
| Связанность | Слабая | Средняя |
| Прозрачность | Низкая | Высокая |
| Добавление участников | Просто | Требует изменения оркестратора |
| Управление сбоями | Распылено по сервисам | Централизовано |
| Сложность при росте шагов | Растёт нелинейно | Растёт линейно |
| Точка отказа | Распределённая | Оркестратор |
Процесс простой (< 4 шагов) и команды независимы?
├── Да → Хореография
└── Нет ↓
Есть долгие таймеры (дни, недели) или human-in-the-loop?
├── Да → Workflow engine (Temporal, Camunda, DBOS)
└── Нет ↓
Нужен централизованный контроль и компенсации?
├── Да → Orchestration-based Saga
└── Нет → Гибрид: choreography на уровне домена + orchestration для сложных потоков
Практическое правило: хореография — для простых, стабильных процессов с 2–3 участниками. Оркестровка — для сложных, многошаговых процессов, где важен контроль, видимость статуса и возможность отката.
В монолите мы привыкли к ACID-транзакциям: если один шаг упал, база данных откатит все изменения. В мире микросервисов у каждого сервиса своя база данных. Двухфазные коммиты (2PC) в распределённых системах работают плохо: блокируют ресурсы, убивают производительность и не масштабируются.
Saga разбивает большую бизнес-транзакцию на цепочку локальных транзакций. Каждая локальная транзакция обновляет свою БД и публикует событие, запускающее следующую. Главное правило: для каждого шага, который может быть отменён, должна быть предусмотрена компенсирующая транзакция.
Saga — это не третий паттерн рядом с хореографией и оркестровкой. Saga реализуется через один из них. Выбор между хореографией и оркестровкой — это выбор способа реализации Saga.
Каждый сервис слушает события и реагирует автономно. Нет центрального координатора. Подходит для 2–4 шагов.
Order Service ──OrderCreated──> Inventory Service
│
InventoryReserved
│
▼
Payment Service
│
PaymentApproved
│
▼
Shipping Service
Если платёж отклонён, PaymentFailed запускает обратную цепочку: Inventory → InventoryReleased → Order → OrderCancelled. Никто не координирует откат — каждый сервис сам знает, как реагировать на PaymentFailed.
Центральный оркестратор управляет последовательностью шагов, принимает решения и координирует компенсации.
┌──────────────┐
│ Saga │
│ Orchestrator│
└──────┬───────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
ReserveInventory ChargePayment ShipOrder
│ │ │
▼ ▼ ▼
Success? Success? Done ✓
│ │
Yes │ No │─── Compensate ──> ReleaseInventory
│ ──> CancelOrder
▼
Next step
Не все шаги саги одинаковы. В литературе по Saga различают три типа:
| Тип | Можно отменить? | Можно повторить? | Пример |
|---|---|---|---|
| Compensable | Да (через компенсацию) | Да | Резервирование товара — компенсация: снятие резерва |
| Pivot | Нет — точка невозврата | — | Списание средств с карты, отгрузка товара |
| Retriable | Не нужно | Да (результат идемпотентен) | Отправка email-подтверждения |
Pivot-транзакция — точка, после которой откат невозможен, только компенсация. До pivot можно откатить всё. После pivot — только двигаться вперёд или компенсировать. Например, списание средств с карты — pivot: вернуть деньги можно только переводом, а не откатом транзакции.
Процесс: 1. Создать заказ → 2. Зарезервировать товар → 3. Списать деньги.
Если шаг 3 (списание денег) терпит неудачу, Saga инициирует «обратный танец»:
InventoryReservationCancelled).OrderCancelled).Компенсирующие шаги не отменяют факт совершения действий в прошлом (как ROLLBACK в SQL), а семантически инвертируют их результат, публикуя новые события.
Каждый шаг саги может быть выполнен повторно. Сеть порвалась после выполнения, но до подтверждения. Оркестратор не получил ack и ретраит команду. Сервис получит команду дважды.
Если шаг не идемпотентен (balance += 100), повторное выполнение удвоит сумму — ровно то, что сага должна предотвращать.
saga_id + step_id как idempotency key. Сервис хранит таблицу обработанных команд: если (saga_id, step_id) уже обработан — вернуть предыдущий результат.INSERT ... ON CONFLICT DO UPDATE вместо голого INSERT.ReleaseInventory для уже освобождённого резерва должен вернуть success, а не ошибку.Таблица (saga_id, step_id) не может расти вечно. Практическое правило: хранить N дней (например, 30), где N превышает максимальное возможное время жизни саги + запас на retry.
-- Ежедневная очистка
DELETE FROM saga_idempotency
WHERE processed_at < NOW() - INTERVAL '30 days';
Не каждая ошибка — повод запускать компенсацию. Различают:
| Стратегия | Когда использовать | Риск |
|---|---|---|
| Fixed delay | Простые, предсказуемые сбои | Дробит нагрузку |
| Exponential backoff | Внешние API, базы данных | Увеличение latency |
| Exponential + jitter | Распределённые системы | Минимизация thundering herd |
| Circuit breaker | Повторяющиеся сбои одного downstream | Защита от каскадного отказа |
public <T> T retryWithBackoff(Supplier<T> action, int maxAttempts) {
for (int attempt = 0; attempt < maxAttempts; attempt++) {
try {
return action.get();
} catch (TransientException e) {
long baseDelay = (long) Math.pow(2, attempt) * 100; // 100ms, 200ms, 400ms...
long jitter = ThreadLocalRandom.current().nextLong(0, baseDelay / 2);
Thread.sleep(baseDelay + jitter);
}
}
throw new SagaCompensationException("Max retries exceeded");
}
Circuit breaker — после N неудач подряд перестать слать запросы на cooldown период. Это защищает перегруженный сервис от добивания и даёт ему время восстановиться.
ACID-транзакции дают изоляцию: параллельные транзакции не видят промежуточных состояний друг друга. Saga не даёт изоляции: другие сервисы могут увидеть «зарезервированный товар» до того, как платёж завершён.
Это создаёт аномалии изоляции:
Решение — Semantic Lock. Сага помечает данные как «в обработке» через специальный статус:
-- Шаг саги: резервирование товара
UPDATE inventory
SET status = 'PENDING_SAGA',
saga_id = 'saga-123',
locked_at = NOW()
WHERE product_id = 42 AND status = 'AVAILABLE';
Другие саги не увидят этот товар как доступный. Semantic lock снимается в конце саги (success или compensation).
Что если сага упала и не сняла semantic lock? Ресурс заблокирован навсегда. Решения:
locked_at + периодический cleanup job, который снимает locks старше N минут (например, 30 минут — максимальное время жизни саги + запас).heartbeat_at в саге. Если heartbeat устарел — сага считается мёртвой, locks снимаются.-- Cleanup job
UPDATE inventory
SET status = 'AVAILABLE',
saga_id = NULL,
locked_at = NULL
WHERE status = 'PENDING_SAGA'
AND locked_at < NOW() - INTERVAL '30 minutes'
AND saga_id NOT IN (SELECT saga_id FROM active_sagas);
Что делать, если шаг саги не отвечает? Сколько ждать? Как решить, что компенсировать?
Правила таймаутов:
// Temporal: таймаут шага
ActivityOptions options = ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(30))
.setRetryOptions(RetryOptions.newBuilder()
.setMaximumAttempts(3)
.build())
.build();
activities.chargePayment(request, options);
// Temporal: общий дедлайн workflow
Workflow.await(Duration.ofMinutes(5),
() -> paymentCompleted || deadlineExceeded);
Команды оркестратора и события сервисов должны публиковаться атомарно с изменением состояния. Иначе возникает Dual Write: БД обновлена, а событие потерялось — или наоборот.
Решение — Transactional Outbox (см. главу IV). При обработке шага саги сервис в одной транзакции:
inventory.status = 'RESERVED').InventoryReserved).Отдельный процесс (relay) забирает события из outbox и публикует их в Kafka. Это гарантирует, что состояние и событие никогда не разойдутся.
BEGIN TRANSACTION;
UPDATE inventory
SET status = 'RESERVED', saga_id = 'saga-123'
WHERE product_id = 42 AND status = 'AVAILABLE';
INSERT INTO outbox (topic, key, payload)
VALUES ('inventory-events', 'prod-42',
'{"type":"InventoryReserved","saga_id":"saga-123"}');
COMMIT;
Каждый шаг Saga (и каждая компенсация) должен публиковать события через outbox. Это единственный способ гарантировать атомарность состояния и события без 2PC.
В каком порядке выполнять компенсации?
Компенсации выполняются в порядке, обратном шагам. Если шаги были A → B → C, компенсации — C → B → A. Это классический подход для саг с зависимостями.
Если шаги независимы (например, отправка уведомлений разным сервисам), компенсации можно выполнять параллельно. Это ускоряет откат.
Правило: если между шагами есть причинная зависимость (B зависит от результата A), компенсации выполняются в обратном порядке. Если шаги независимы — параллельно.
Компенсация шага A может зависеть от того, что компенсация шага B уже выполнена (например, нельзя снять резерв, пока не возвращены деньги). Это ещё один аргумент за обратный порядок по умолчанию.
Простой оркестратор, живущий в памяти, — хрупкая конструкция. При рестарте приложения состояние саги теряется. Process Manager — это паттерн долгоживущего оркестратора, который персистентно хранит своё состояние и может восстановиться после сбоя.
Process Manager хранит:
Event sourcing и Saga — естественное сочетание. Состояние саги = последовательность событий саги.
Saga Events (append-only log)
├── SagaStarted { saga_id, order_id }
├── InventoryReserved { saga_id, product_id }
├── PaymentCharged { saga_id, amount }
├── ShippingScheduled { saga_id, tracking }
└── SagaCompleted { saga_id }
Текущее состояние саги материализуется из журнала: saga_id → current_step, status, history. Компенсация — это новые события (PaymentRefunded, InventoryReleased), а не удаление старых.
KTable<String, SagaState> sagaStates = builder
.stream("saga-events")
.groupByKey()
.aggregate(
() -> new SagaState(),
(sagaId, event, state) -> state.apply(event),
Materialized.<String, SagaState, KeyValueStore<Bytes, byte[]>>as(
"saga-state-store")
);
// Восстановление после сбоя — автоматически из changelog
// Продолжение с последнего незавершённого шага
Как обновить логику саги, если в системе уже есть активные саги со старой версией? Простое обновление кода сломает их.
Каждая сага имеет версию (например, OrderSaga_v1, OrderSaga_v2). Новые саги запускаются на новой версии, старые завершаются на старой. Temporal поддерживает это нативно.
Добавление новых шагов — в конце. Удаление — через deprecation period. Изменение существующих шагов — только совместимое (как schema evolution в главе III).
Никогда не меняйте логику активной саги in-place. Запускайте новую версию для новых саг, а старые завершайте на старой версии.
Не все процессы можно «откатить» технически. Авиакомпания продала больше билетов, чем мест (овербукинг). Платёж прошёл, посадочный талон выдан. Технический откат — забрать деньги и аннулировать билет в аэропорту — катастрофа для бизнеса и репутации.
Здесь вступают в силу бизнес-компенсации. Вместо технического отката Saga генерирует FlightOverbooked, который запускает другой, человеческий или полуавтоматический процесс:
Если бизнес-компенсация требует участия человека, это моделируется через workflow engine с wait-for-signal:
// Temporal: ожидание ответа человека
Workflow.await(Duration.ofHours(24),
() -> humanDecisionReceived);
if (humanDecisionReceived) {
activities.proceedWithAlternative(decision);
} else {
activities.autoCompensate();
}
Построение оркестратора с нуля — сложная задача. На рынке есть зрелые workflow engines, которые берут на себя персистентность состояния, retry, таймауты и компенсации.
| Инструмент | Модель | Особенность |
|---|---|---|
| Temporal | Code-first workflow engine | Пишете сагу как обычный код (Go, TS, Python, Java). Durable execution — при сбое worker'а продолжение с точки останова. |
| Camunda | BPMN-engine | Графический дизайн процесса. Подходит, если процессы моделируются бизнес-аналитиками. |
| AWS Step Functions | Managed state machine | ASL (JSON-based DSL). Хорошая интеграция с AWS, но lock-in. |
| Restate | Durable functions | Лёгкий, Rust-based. Workflow-as-code, target для микросервисов. |
| DBOS | Database-centric | Состояние в PostgreSQL. Прозрачность и надёжность БД. |
public class OrderSagaWorkflowImpl implements OrderSagaWorkflow {
private final Saga saga = new Saga(new Saga.Options.Builder()
.setParallelCompensation(false)
.build());
@Override
public void execute(OrderRequest request) {
try {
activities.reserveInventory(request);
saga.addCompensation(activities::releaseInventory, request);
activities.chargePayment(request);
saga.addCompensation(activities::refundPayment, request);
activities.shipOrder(request);
// shipOrder — pivot: не имеет компенсации
} catch (Exception e) {
saga.compensate(); // автоматически в обратном порядке
throw e;
}
}
}
Ключевое свойство Temporal — durable execution: если worker, выполняющий сагу, падает, другой worker подхватывает с того же шага. Состояние шагов и история вызовов сохраняются.
Два заказа на один и тот же товар. Кто первый забронировал? Без изоляции — race condition.
status = PENDING), предотвращая параллельные резервирования.Как отследить статус конкретной саги в хореографии, где нет центрального координатора?
saga_id. Обновляется каждым сервисом при выполнении шага.compensating дольше M минут — критический алерт.| Метрика | Что показывает | Порог |
|---|---|---|
saga_duration_p99 |
99-й перцентиль времени саги | > SLA — алерт |
saga_stuck_count |
Число зависших саг | > 0 в течение 5 минут |
compensation_rate |
Доля саг, требующих компенсации | > 5% — расследование |
pivot_step_failure_rate |
Ошибки на pivot-шагах | > 0.1% — критический алерт |
| Глава | Фокус | Связь с текущей главой |
|---|---|---|
| III. Анатомия идеального события | Контракты и Schema Registry | События саги — публичные контракты с эволюцией схем |
| IV. Data Liberation | Transactional Outbox | Атомарная публикация команд и событий саги |
| VII. Память микросервисов | State stores | Состояние саги — state store, восстанавливаемый из changelog |
| VIII. Механика бизнес-процессов | Saga, оркестровка, компенсации | Сквозные процессы поверх независимых микросервисов |
saga_id + step_id как idempotency key)Понимая, когда нужно выйти на танцплощадку, а когда взять в руки дирижёрскую палочку, вы сможете проектировать системы, которые выживают в хаосе реальных бизнес-требований — и не превращаются в распределённый монолит, который никто не может отладить.
Глава VIII цикла «Событийно-управляемая архитектура». Источники: microservices.io, Temporal, Camunda, AWS Step Functions, Restate, DBOS, Marcustan, OpenTelemetry, практические кейсы production-систем.
Until now we have viewed microservices as isolated "cells" of an organism: each has its own bounded context, its own database, and its own business logic. But real business doesn't live within the boundaries of one service. Order placement, travel booking, or credit approval — these are cross-cutting business processes that cross the boundaries of many microservices.
How do we organize interaction between these independent actors to guarantee the integrity of a business operation? In EDM architecture, two fundamental patterns are used — choreography and orchestration. And when it comes to data consistency, the Saga pattern takes the stage.
This is the eighth chapter of the series. It builds on Chapter III (event contracts), Chapter IV (Transactional Outbox), and Chapter VII (state stores). Here we explore how these mechanisms combine into cross-cutting business processes.
In choreographic architecture there is no central governing body. Each microservice independently "listens" to event streams and reacts to them by publishing its own events in response. This is like dancers at a party: each knows their steps and moves to the common music, but there is no conductor.
Service A completes its part of the work and publishes OrderCreated. Service B, responsible for inventory reservation, is subscribed to this stream. Having received the event, it reserves the product and publishes InventoryReserved. Service C (payment) picks up the event — and so on.
Service A doesn't know about the existence of services B and C. It simply throws the fact into the "air." New services can appear by simply subscribing to the needed streams, without changing the source code.
// Order Service
KStream<String, Order> orders = builder.stream("orders");
orders
.filter((k, o) -> o.getStatus() == CREATED)
.mapValues(o -> new InventoryReserveCommand(o.getOrderId(), o.getItems()))
.to("inventory-commands");
// Inventory Service
KStream<String, InventoryReserveCommand> cmds = builder.stream("inventory-commands");
KStream<String, InventoryReserved>[] branches = cmds
.mapValues(this::tryReserve)
.branch(
(k, r) -> r.isSuccess(),
(k, r) -> !r.isSuccess()
);
branches[0].to("inventory-events"); // success
branches[1].to("inventory-failures"); // triggers compensation
There is no orchestrator here. Each service decides for itself how to react to success or failure.
Orchestration assumes a central coordinator. It contains all the workflow logic, gives commands to subordinate microservices (via events or direct calls), and waits for their responses to decide the next step.
The orchestrator receives an order creation request. It sends a command to the inventory service: "Reserve the product." Having received confirmation, it sends a command to the payment service: "Charge the funds." If a failure occurs at any stage, the orchestrator initiates a rollback of already performed actions.
The orchestrator stores saga state — the current step, history, and result of each step. This allows knowing where the process stopped, making compensation decisions, and recovering after a failure.
| Criterion | Choreography | Orchestration |
|---|---|---|
| Coupling | Loose | Medium |
| Transparency | Low | High |
| Adding participants | Simple | Requires orchestrator change |
| Failure management | Distributed | Centralized |
| Complexity as steps grow | Non-linear | Linear |
| Failure point | Distributed | Orchestrator |
Is the process simple (< 4 steps) and are teams independent?
├── Yes → Choreography
└── No ↓
Are there long timers (days, weeks) or human-in-the-loop?
├── Yes → Workflow engine (Temporal, Camunda, DBOS)
└── No ↓
Is centralized control and compensation needed?
├── Yes → Orchestration-based Saga
└── No → Hybrid: choreography at domain level + orchestration for complex flows
Rule of thumb: choreography — for simple, stable processes with 2–3 participants. Orchestration — for complex, multi-step processes where control, status visibility, and rollback matter.
In a monolith we are used to ACID transactions: if one step fails, the database rolls back all changes. In the microservices world, each service has its own database. Two-phase commits (2PC) work poorly in distributed systems: they block resources, kill performance, and don't scale.
Saga splits a large business transaction into a chain of local transactions. Each local transaction updates its DB and publishes an event that triggers the next one. The main rule: for every step that can be undone, a compensating transaction must be provided.
Saga is not a third pattern next to choreography and orchestration. Saga is implemented through one of them. The choice between choreography and orchestration is the choice of how to implement Saga.
Each service listens to events and reacts autonomously. No central coordinator. Suitable for 2–4 steps.
Order Service ──OrderCreated──> Inventory Service
│
InventoryReserved
│
▼
Payment Service
│
PaymentApproved
│
▼
Shipping Service
If payment is declined, PaymentFailed triggers the reverse chain: Inventory → InventoryReleased → Order → OrderCancelled. No one coordinates the rollback — each service knows how to react to PaymentFailed.
┌──────────────┐
│ Saga │
│ Orchestrator│
└──────┬───────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
ReserveInventory ChargePayment ShipOrder
│ │ │
▼ ▼ ▼
Success? Success? Done ✓
│ │
Yes │ No │─── Compensate ──> ReleaseInventory
│ ──> CancelOrder
▼
Next step
| Type | Can Be Undone? | Can Be Repeated? | Example |
|---|---|---|---|
| Compensable | Yes (via compensation) | Yes | Inventory reservation — compensation: release reservation |
| Pivot | No — point of no return | — | Card charge, order shipment |
| Retriable | Not needed | Yes (idempotent result) | Sending confirmation email |
Pivot transaction — the point after which rollback is impossible, only compensation. Before pivot you can roll back everything. After pivot — only move forward or compensate.
Process: 1. Create order → 2. Reserve product → 3. Charge payment.
If step 3 (payment) fails, Saga initiates the "reverse dance":
InventoryReservationCancelled).OrderCancelled).Compensating steps do not undo the fact of past actions (like ROLLBACK in SQL), but semantically invert their result by publishing new events.
Each saga step may be executed multiple times. The network broke after execution but before confirmation. The orchestrator didn't receive an ack and retries the command. The service receives the command twice.
If the step is not idempotent (balance += 100), the retry doubles the amount — exactly what the saga should prevent.
saga_id + step_id as idempotency key. The service stores a table of processed commands: if (saga_id, step_id) is already processed — return the previous result.INSERT ... ON CONFLICT DO UPDATE.ReleaseInventory for an already released reservation should return success, not an error.-- Daily cleanup
DELETE FROM saga_idempotency
WHERE processed_at < NOW() - INTERVAL '30 days';
Not every failure warrants compensation:
| Strategy | When to Use | Risk |
|---|---|---|
| Fixed delay | Simple, predictable failures | Load spikes |
| Exponential backoff | External APIs, databases | Increased latency |
| Exponential + jitter | Distributed systems | Minimizes thundering herd |
| Circuit breaker | Recurring failures of one downstream | Cascading failure protection |
public <T> T retryWithBackoff(Supplier<T> action, int maxAttempts) {
for (int attempt = 0; attempt < maxAttempts; attempt++) {
try {
return action.get();
} catch (TransientException e) {
long baseDelay = (long) Math.pow(2, attempt) * 100;
long jitter = ThreadLocalRandom.current().nextLong(0, baseDelay / 2);
Thread.sleep(baseDelay + jitter);
}
}
throw new SagaCompensationException("Max retries exceeded");
}
ACID transactions provide isolation: parallel transactions don't see each other's intermediate states. Saga does not provide isolation: other services may see "reserved product" before payment is completed.
This creates isolation anomalies:
Solution — Semantic Lock. The saga marks data as "in process" via a special status:
-- Saga step: reserve product
UPDATE inventory
SET status = 'PENDING_SAGA',
saga_id = 'saga-123',
locked_at = NOW()
WHERE product_id = 42 AND status = 'AVAILABLE';
locked_at field + periodic cleanup job that releases locks older than N minutes.heartbeat_at. If stale — the saga is considered dead, locks released.-- Cleanup job
UPDATE inventory
SET status = 'AVAILABLE',
saga_id = NULL,
locked_at = NULL
WHERE status = 'PENDING_SAGA'
AND locked_at < NOW() - INTERVAL '30 minutes'
AND saga_id NOT IN (SELECT saga_id FROM active_sagas);
What to do if a saga step doesn't respond? How long to wait? How to decide what to compensate?
// Temporal: step timeout
ActivityOptions options = ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(30))
.setRetryOptions(RetryOptions.newBuilder()
.setMaximumAttempts(3)
.build())
.build();
activities.chargePayment(request, options);
// Temporal: overall workflow deadline
Workflow.await(Duration.ofMinutes(5),
() -> paymentCompleted || deadlineExceeded);
Orchestrator commands and service events must be published atomically with state changes. Otherwise Dual Write occurs.
The solution is Transactional Outbox (see Chapter IV). When processing a saga step, the service in one transaction:
inventory.status = 'RESERVED').InventoryReserved).BEGIN TRANSACTION;
UPDATE inventory
SET status = 'RESERVED', saga_id = 'saga-123'
WHERE product_id = 42 AND status = 'AVAILABLE';
INSERT INTO outbox (topic, key, payload)
VALUES ('inventory-events', 'prod-42',
'{"type":"InventoryReserved","saga_id":"saga-123"}');
COMMIT;
Every Saga step (and every compensation) must publish events via outbox. This is the only way to guarantee atomicity of state and event without 2PC.
Compensations execute in reverse order of steps. A → B → C, compensations — C → B → A. Classic approach for sagas with dependencies.
If steps are independent, compensations can run in parallel. Faster rollback.
Rule: if there is a causal dependency between steps (B depends on A's result), compensations execute in reverse order. If steps are independent — in parallel.
Process Manager is a pattern of a long-lived orchestrator that persistently stores its state and can recover after a failure.
Process Manager stores:
Saga Events (append-only log)
├── SagaStarted { saga_id, order_id }
├── InventoryReserved { saga_id, product_id }
├── PaymentCharged { saga_id, amount }
├── ShippingScheduled { saga_id, tracking }
└── SagaCompleted { saga_id }
KTable<String, SagaState> sagaStates = builder
.stream("saga-events")
.groupByKey()
.aggregate(
() -> new SagaState(),
(sagaId, event, state) -> state.apply(event),
Materialized.<String, SagaState, KeyValueStore<Bytes, byte[]>>as(
"saga-state-store")
);
// Recovery after failure — automatically from changelog
// Continue from last incomplete step
Each saga has a version (OrderSaga_v1, OrderSaga_v2). New sagas start on the new version, old ones complete on the old. Temporal supports this natively.
Adding new steps — at the end. Removing — via deprecation period. Modifying existing steps — only compatibly.
Never change the logic of an active saga in-place. Start a new version for new sagas, complete old ones on the old version.
Not all processes can be technically "rolled back." An airline sold more tickets than seats (overbooking). Payment went through, boarding pass issued. Technical rollback — taking money back and voiding the ticket at the airport — is a catastrophe for business and reputation.
Here business compensations come into play. Instead of technical rollback, the Saga generates FlightOverbooked, which triggers another, human or semi-automatic process:
// Temporal: waiting for human decision
Workflow.await(Duration.ofHours(24),
() -> humanDecisionReceived);
if (humanDecisionReceived) {
activities.proceedWithAlternative(decision);
} else {
activities.autoCompensate();
}
| Tool | Model | Feature |
|---|---|---|
| Temporal | Code-first workflow engine | Write saga as regular code (Go, TS, Python, Java). Durable execution — recovery from failure point. |
| Camunda | BPMN engine | Visual process design. Suitable when processes are modeled by business analysts. |
| AWS Step Functions | Managed state machine | ASL (JSON-based DSL). Good AWS integration, but lock-in. |
| Restate | Durable functions | Lightweight, Rust-based. Workflow-as-code, target for microservices. |
| DBOS | Database-centric | State in PostgreSQL. Transparency and reliability of DB. |
public class OrderSagaWorkflowImpl implements OrderSagaWorkflow {
private final Saga saga = new Saga(new Saga.Options.Builder()
.setParallelCompensation(false)
.build());
@Override
public void execute(OrderRequest request) {
try {
activities.reserveInventory(request);
saga.addCompensation(activities::releaseInventory, request);
activities.chargePayment(request);
saga.addCompensation(activities::refundPayment, request);
activities.shipOrder(request);
// shipOrder — pivot: no compensation
} catch (Exception e) {
saga.compensate(); // automatically in reverse order
throw e;
}
}
}
status = PENDING).saga_id.| Metric | What It Shows | Threshold |
|---|---|---|
saga_duration_p99 |
99th percentile of saga duration | > SLA — alert |
saga_stuck_count |
Number of stuck sagas | > 0 for 5 minutes |
compensation_rate |
Share of sagas requiring compensation | > 5% — investigation |
pivot_step_failure_rate |
Failures at pivot steps | > 0.1% — critical alert |
| Chapter | Focus | Link to This Chapter |
|---|---|---|
| III. The Anatomy of an Ideal Event | Contracts and Schema Registry | Saga events are public contracts with schema evolution |
| IV. Data Liberation | Transactional Outbox | Atomic publication of saga commands and events |
| VII. The Memory of Microservices | State stores | Saga state — a state store restored from changelog |
| VIII. The Mechanics of Business Processes | Saga, orchestration, compensations | Cross-cutting processes over independent microservices |
saga_id + step_id as idempotency key)Knowing when to step onto the dance floor and when to take up the conductor's baton, you can design systems that survive the chaos of real business requirements — and don't turn into a distributed monolith that no one can debug.
Chapter VIII of the "Event-Driven Architecture" series. Sources: microservices.io, Temporal, Camunda, AWS Step Functions, Restate, DBOS, Marcustan, OpenTelemetry, production system case studies.