FinCodeCow · Глава VIII

Механика бизнес-процессов

Оркестровка, хореография и распределённые транзакции: от pivot-транзакций до workflow engines

До сих пор мы рассматривали микросервисы как изолированные «клетки» организма: каждая имеет свой ограниченный контекст, свою базу данных и свою бизнес-логику. Но реальный бизнес не живёт внутри границ одного сервиса. Оформление заказа, бронирование путешествия или одобрение кредита — это сквозные бизнес-процессы, которые пересекают границы множества микросервисов.

Как организовать взаимодействие между этими независимыми акторами, чтобы гарантировать целостность бизнес-операции? В архитектуре EDM используются два фундаментальных паттерна — хореография и оркестровка. А когда речь заходит о согласованности данных, на сцену выходит паттерн Saga.

Это восьмая глава цикла. Она опирается на главу III (контракты событий), главу IV (Transactional Outbox) и главу VII (state stores). Здесь мы разберём, как эти механизмы складываются в сквозные бизнес-процессы.

• • •

IХореография: танцплощадка событий

В хореографической архитектуре нет центрального управляющего органа. Каждый микросервис самостоятельно «слушает» потоки событий и реагирует на них, публикуя свои собственные события в ответ. Это напоминает танцоров на вечеринке: каждый знает свои шаги и движется в ритм общей музыки, но дирижёра нет.

Сервис А завершает свою часть работы и публикует OrderCreated. Сервис Б, отвечающий за резервирование товаров, подписан на этот поток. Получив событие, он резервирует товар и публикует InventoryReserved. Сервис В (списание средств) подхватывает событие — и так далее.

Сервис А не знает о существовании сервисов Б и В. Он просто бросает факт в «эфир». Новые сервисы могут появиться, просто начав подписываться на нужные потоки, без изменения кода источника.

Пример choreography-based saga на Kafka Streams

// 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

Здесь нет оркестратора. Каждый сервис сам решает, как реагировать на успех или неудачу.

• • •

IIОркестровка: симфонический дирижёр

Оркестрация предполагает наличие центрального координатора. Он содержит в себе всю логику рабочего процесса, отдаёт команды подчинённым микросервисам (через события или прямые вызовы) и ждёт от них ответов, чтобы принять решение о следующем шаге.

Оркестратор получает запрос на создание заказа. Он отправляет команду сервису инвентаря: «Зарезервируй товар». Получив подтверждение, он отправляет команду платёжному сервису: «Спиши средства». Если на любом этапе происходит сбой, оркестратор инициирует откат уже выполненных действий.

Ключевое отличие

Оркестратор хранит состояние саги — текущий шаг, историю, результат каждого шага. Это позволяет знать, где остановился процесс, принимать решение о компенсации и восстанавливаться после сбоя.

• • •

IIIСравнение и decision tree

Критерий Хореография Оркестровка
Связанность Слабая Средняя
Прозрачность Низкая Высокая
Добавление участников Просто Требует изменения оркестратора
Управление сбоями Распылено по сервисам Централизовано
Сложность при росте шагов Растёт нелинейно Растёт линейно
Точка отказа Распределённая Оркестратор

Decision tree: что выбрать?

Процесс простой (< 4 шагов) и команды независимы?
    ├── Да → Хореография
    └── Нет ↓

Есть долгие таймеры (дни, недели) или human-in-the-loop?
    ├── Да → Workflow engine (Temporal, Camunda, DBOS)
    └── Нет ↓

Нужен централизованный контроль и компенсации?
    ├── Да → Orchestration-based Saga
    └── Нет → Гибрид: choreography на уровне домена + orchestration для сложных потоков

Практическое правило: хореография — для простых, стабильных процессов с 2–3 участниками. Оркестровка — для сложных, многошаговых процессов, где важен контроль, видимость статуса и возможность отката.

• • •

IVSaga: не третий паттерн, а реализация

В монолите мы привыкли к ACID-транзакциям: если один шаг упал, база данных откатит все изменения. В мире микросервисов у каждого сервиса своя база данных. Двухфазные коммиты (2PC) в распределённых системах работают плохо: блокируют ресурсы, убивают производительность и не масштабируются.

Saga разбивает большую бизнес-транзакцию на цепочку локальных транзакций. Каждая локальная транзакция обновляет свою БД и публикует событие, запускающее следующую. Главное правило: для каждого шага, который может быть отменён, должна быть предусмотрена компенсирующая транзакция.

Важное разъяснение

Saga — это не третий паттерн рядом с хореографией и оркестровкой. Saga реализуется через один из них. Выбор между хореографией и оркестровкой — это выбор способа реализации Saga.

• • •

VДва способа координации Saga

Choreography-based Saga

Каждый сервис слушает события и реагирует автономно. Нет центрального координатора. Подходит для 2–4 шагов.

Order Service ──OrderCreated──> Inventory Service
                                    │
                          InventoryReserved
                                    │
                                    ▼
                              Payment Service
                                    │
                          PaymentApproved
                                    │
                                    ▼
                              Shipping Service

Если платёж отклонён, PaymentFailed запускает обратную цепочку: Inventory → InventoryReleased → Order → OrderCancelled. Никто не координирует откат — каждый сервис сам знает, как реагировать на PaymentFailed.

Orchestration-based Saga

Центральный оркестратор управляет последовательностью шагов, принимает решения и координирует компенсации.

                    ┌──────────────┐
                    │  Saga        │
                    │  Orchestrator│
                    └──────┬───────┘
                           │
            ┌──────────────┼──────────────┐
            ▼              ▼              ▼
     ReserveInventory  ChargePayment  ShipOrder
            │              │              │
            ▼              ▼              ▼
         Success?       Success?       Done ✓
            │              │
         Yes │           No  │─── Compensate ──> ReleaseInventory
            │                                   ──> CancelOrder
            ▼
       Next step
• • •

VIТипология шагов Saga: pivot, compensable, retrievable

Не все шаги саги одинаковы. В литературе по Saga различают три типа:

Тип Можно отменить? Можно повторить? Пример
Compensable Да (через компенсацию) Да Резервирование товара — компенсация: снятие резерва
Pivot Нет — точка невозврата — Списание средств с карты, отгрузка товара
Retriable Не нужно Да (результат идемпотентен) Отправка email-подтверждения

Pivot-транзакция — точка, после которой откат невозможен, только компенсация. До pivot можно откатить всё. После pivot — только двигаться вперёд или компенсировать. Например, списание средств с карты — pivot: вернуть деньги можно только переводом, а не откатом транзакции.

• • •

VIIКак работает откат в Saga

Процесс: 1. Создать заказ → 2. Зарезервировать товар → 3. Списать деньги.

Если шаг 3 (списание денег) терпит неудачу, Saga инициирует «обратный танец»:

  • Компенсирующий шаг 2: Снять резерв с товара (InventoryReservationCancelled).
  • Компенсирующий шаг 1: Отменить заказ (OrderCancelled).

Компенсирующие шаги не отменяют факт совершения действий в прошлом (как ROLLBACK в SQL), а семантически инвертируют их результат, публикуя новые события.

• • •

VIIIИдемпотентность шагов и компенсаций

Каждый шаг саги может быть выполнен повторно. Сеть порвалась после выполнения, но до подтверждения. Оркестратор не получил ack и ретраит команду. Сервис получит команду дважды.

Если шаг не идемпотентен (balance += 100), повторное выполнение удвоит сумму — ровно то, что сага должна предотвращать.

Правила идемпотентности шагов Saga

  1. Использовать saga_id + step_id как idempotency key. Сервис хранит таблицу обработанных команд: если (saga_id, step_id) уже обработан — вернуть предыдущий результат.
  2. Upsert вместо insert. INSERT ... ON CONFLICT DO UPDATE вместо голого INSERT.
  3. Внешние вызовы (платёжный шлюз) — передавать idempotency key в заголовке (Stripe, Adyen поддерживают это нативно).
  4. Компенсации тоже должны быть идемпотентны. ReleaseInventory для уже освобождённого резерва должен вернуть success, а не ошибку.

Idempotency window: как долго хранить?

Таблица (saga_id, step_id) не может расти вечно. Практическое правило: хранить N дней (например, 30), где N превышает максимальное возможное время жизни саги + запас на retry.

-- Ежедневная очистка
DELETE FROM saga_idempotency
WHERE processed_at < NOW() - INTERVAL '30 days';
• • •

IXRetry strategies и circuit breaker

Не каждая ошибка — повод запускать компенсацию. Различают:

  • Transient failures (сеть, таймаут, 503) — retry с backoff.
  • Business failures (недостаточно средств, товар не найден) — компенсация.
Стратегия Когда использовать Риск
Fixed delay Простые, предсказуемые сбои Дробит нагрузку
Exponential backoff Внешние API, базы данных Увеличение latency
Exponential + jitter Распределённые системы Минимизация thundering herd
Circuit breaker Повторяющиеся сбои одного downstream Защита от каскадного отказа

Пример retry с exponential backoff + jitter

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 период. Это защищает перегруженный сервис от добивания и даёт ему время восстановиться.

• • •

XSemantic lock и его cleanup

ACID-транзакции дают изоляцию: параллельные транзакции не видят промежуточных состояний друг друга. Saga не даёт изоляции: другие сервисы могут увидеть «зарезервированный товар» до того, как платёж завершён.

Это создаёт аномалии изоляции:

  • Lost update: две саги одновременно резервируют один товар — оба видят «доступно», один перезатирает другого.
  • Dirty read: сервис аналитики видит «заказ оплачен», но платёж ещё не подтверждён.
  • Non-repeatable read: два чтения одного состояния в течение саги дают разные результаты.

Решение — 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).

Cleanup застрявших semantic locks

Что если сага упала и не сняла semantic lock? Ресурс заблокирован навсегда. Решения:

  1. TTL на lock. Поле locked_at + периодический cleanup job, который снимает locks старше N минут (например, 30 минут — максимальное время жизни саги + запас).
  2. Heartbeat от оркестратора. Оркестратор периодически обновляет heartbeat_at в саге. Если heartbeat устарел — сага считается мёртвой, locks снимаются.
  3. Explicit reconciliation job. Отдельный сервис, который сканирует активные саги и сверяет состояние с semantic 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);
• • •

XIDeadline и таймауты саги

Что делать, если шаг саги не отвечает? Сколько ждать? Как решить, что компенсировать?

Правила таймаутов:

  1. Per-step timeout. Каждый шаг имеет свой таймаут (например, 30 секунд для вызова платёжного шлюза). По истечении — retry или компенсация.
  2. Saga-level deadline. У саги есть общий дедлайн (например, 5 минут). Если дедлайн истёк — принудительная компенсация.
  3. Long-running 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);
• • •

XIIOutbox в контексте Saga

Команды оркестратора и события сервисов должны публиковаться атомарно с изменением состояния. Иначе возникает Dual Write: БД обновлена, а событие потерялось — или наоборот.

Решение — Transactional Outbox (см. главу IV). При обработке шага саги сервис в одной транзакции:

  1. Обновляет бизнес-состояние (inventory.status = 'RESERVED').
  2. Записывает событие в outbox-таблицу (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.

• • •

XIIICompensation ordering

В каком порядке выполнять компенсации?

Обратный порядок

Компенсации выполняются в порядке, обратном шагам. Если шаги были A → B → C, компенсации — C → B → A. Это классический подход для саг с зависимостями.

Параллельные компенсации

Если шаги независимы (например, отправка уведомлений разным сервисам), компенсации можно выполнять параллельно. Это ускоряет откат.

Правило: если между шагами есть причинная зависимость (B зависит от результата A), компенсации выполняются в обратном порядке. Если шаги независимы — параллельно.

Важно

Компенсация шага A может зависеть от того, что компенсация шага B уже выполнена (например, нельзя снять резерв, пока не возвращены деньги). Это ещё один аргумент за обратный порядок по умолчанию.

• • •

XIVProcess Manager и event sourcing

Простой оркестратор, живущий в памяти, — хрупкая конструкция. При рестарте приложения состояние саги теряется. Process Manager — это паттерн долгоживущего оркестратора, который персистентно хранит своё состояние и может восстановиться после сбоя.

Process Manager хранит:

  • Текущее состояние процесса — на каком шаге, какие шаги выполнены.
  • Correlation ID — связывает все события одной саги.
  • Историю — результат каждого шага для аудита и восстановления.
  • Таймеры — для таймаутов.

Saga и Event Sourcing — естественная пара

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), а не удаление старых.

Пример Process Manager на Kafka Streams

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
// Продолжение с последнего незавершённого шага
• • •

XVSaga versioning: обновление без потери активных саг

Как обновить логику саги, если в системе уже есть активные саги со старой версией? Простое обновление кода сломает их.

Versioned workflows

Каждая сага имеет версию (например, OrderSaga_v1, OrderSaga_v2). Новые саги запускаются на новой версии, старые завершаются на старой. Temporal поддерживает это нативно.

Backward-compatible шаги

Добавление новых шагов — в конце. Удаление — через deprecation period. Изменение существующих шагов — только совместимое (как schema evolution в главе III).

Правило

Никогда не меняйте логику активной саги in-place. Запускайте новую версию для новых саг, а старые завершайте на старой версии.

• • •

XVIБизнес-компенсации: когда техника бессильна

Не все процессы можно «откатить» технически. Авиакомпания продала больше билетов, чем мест (овербукинг). Платёж прошёл, посадочный талон выдан. Технический откат — забрать деньги и аннулировать билет в аэропорту — катастрофа для бизнеса и репутации.

Здесь вступают в силу бизнес-компенсации. Вместо технического отката Saga генерирует FlightOverbooked, который запускает другой, человеческий или полуавтоматический процесс:

  • Служба поддержки получает уведомление.
  • Клиенту предлагается альтернативный рейс.
  • На счёт клиента зачисляются бонусные мили или voucher.

Human-in-the-loop

Если бизнес-компенсация требует участия человека, это моделируется через workflow engine с wait-for-signal:

// Temporal: ожидание ответа человека
Workflow.await(Duration.ofHours(24),
    () -> humanDecisionReceived);

if (humanDecisionReceived) {
    activities.proceedWithAlternative(decision);
} else {
    activities.autoCompensate();
}

Другие случаи бизнес-компенсаций

  • Chargeback (банковский возврат): клиент оспаривает транзакцию. Компенсация — дебет счёта продавца через N дней.
  • Частичный откат: из заказа на 10 позиций отменены 3. Компенсация — вернуть деньги за 3 позиции, не отменяя заказ полностью.
  • Отложенный платёж: банковский перевод приходит через 2 дня. Сага ждёт подтверждения, а не откатывает сразу.
• • •

XVIIWorkflow engines: Temporal, Camunda, Step Functions

Построение оркестратора с нуля — сложная задача. На рынке есть зрелые 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. Прозрачность и надёжность БД.

Пример саги на Temporal с встроенными compensations

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 подхватывает с того же шага. Состояние шагов и история вызовов сохраняются.

• • •

XVIIIConcurrent sagas и разрешение конфликтов

Два заказа на один и тот же товар. Кто первый забронировал? Без изоляции — race condition.

  • Optimistic concurrency control. Каждый шаг саги проверяет версию данных. Если версия изменилась — откат с перезапуском саги.
  • Pessimistic locking (semantic lock). Шаг «резервирование» немедленно блокирует товар (status = PENDING), предотвращая параллельные резервирования.
  • Commutative operations. Если операции коммутативны (порядок не важен), конфликты не возникают. Например, добавление товаров в корзину — два add-а дают одинаковый результат.
  • Saga ordering by priority. Для финансовых саг — упорядочивание по timestamp или priority queue.
• • •

XIXObservability бизнес-процессов

Как отследить статус конкретной саги в хореографии, где нет центрального координатора?

  • Correlation ID — во всех событиях одной саги. Позволяет собрать цепочку событий в distributed tracing.
  • Saga state store — отдельная таблица/БД, хранящая статус саги по saga_id. Обновляется каждым сервисом при выполнении шага.
  • Dashboard — показывает все активные саги, их текущий шаг, возраст, статус (running, compensating, completed, failed).
  • Alerting — сага, зависшая на одном шаге дольше N минут — алерт. Сага в статусе compensating дольше M минут — критический алерт.
  • Distributed tracing — OpenTelemetry trace для каждой саги, span для каждого шага.

Метрики для мониторинга

Метрика Что показывает Порог
saga_duration_p99 99-й перцентиль времени саги > SLA — алерт
saga_stuck_count Число зависших саг > 0 в течение 5 минут
compensation_rate Доля саг, требующих компенсации > 5% — расследование
pivot_step_failure_rate Ошибки на pivot-шагах > 0.1% — критический алерт
• • •

XXЧеклисты и связь с циклом

Связь с предыдущими главами

Глава Фокус Связь с текущей главой
III. Анатомия идеального события Контракты и Schema Registry События саги — публичные контракты с эволюцией схем
IV. Data Liberation Transactional Outbox Атомарная публикация команд и событий саги
VII. Память микросервисов State stores Состояние саги — state store, восстанавливаемый из changelog
VIII. Механика бизнес-процессов Saga, оркестровка, компенсации Сквозные процессы поверх независимых микросервисов

Чеклист проектирования Saga

  • Выбран способ координации: хореография, оркестровка или workflow engine (decision tree)
  • Определены типы шагов: compensable / pivot / retrievable
  • Для каждого compensable шага определена компенсация
  • Все шаги и компенсации идемпотентны (saga_id + step_id как idempotency key)
  • Idempotency window определён (N дней)
  • Retry strategy настроена (exponential backoff + jitter)
  • Circuit breaker настроен для внешних вызовов
  • Semantic locks используются для изоляции
  • TTL для semantic locks + cleanup job
  • Per-step и saga-level таймауты настроены
  • Публикация команд и событий через Transactional Outbox
  • Compensation ordering определён (обратный порядок или параллельно)
  • Process Manager персистентно хранит состояние
  • Saga versioning: новые саги на новой версии, старые — на старой
  • Бизнес-компенсации для случаев, где технический откат невозможен
  • Human-in-the-loop через wait-for-signal (если нужно)
  • Correlation ID во всех событиях саги
  • Dashboard и алерты на зависшие саги
  • Метрики: duration, stuck count, compensation rate, pivot failures
Финальный тезис

Понимая, когда нужно выйти на танцплощадку, а когда взять в руки дирижёрскую палочку, вы сможете проектировать системы, которые выживают в хаосе реальных бизнес-требований — и не превращаются в распределённый монолит, который никто не может отладить.

Глава VIII цикла «Событийно-управляемая архитектура». Источники: microservices.io, Temporal, Camunda, AWS Step Functions, Restate, DBOS, Marcustan, OpenTelemetry, практические кейсы production-систем.

FinCodeCow · Chapter VIII

The Mechanics of Business Processes

Orchestration, choreography, and distributed transactions: from pivot transactions to workflow engines

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.

• • •

IChoreography: The Event Dance Floor

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.

Example of choreography-based saga on Kafka Streams

// 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.

• • •

IIOrchestration: The Symphony Conductor

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.

Key Difference

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.

• • •

IIIComparison and Decision Tree

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

Decision tree: what to choose?

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.

• • •

IVSaga: Not a Third Pattern, But an Implementation

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.

Important Clarification

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.

• • •

VTwo Ways to Coordinate Saga

Choreography-based 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.

Orchestration-based Saga

                    ┌──────────────┐
                    │  Saga        │
                    │  Orchestrator│
                    └──────┬───────┘
                           │
            ┌──────────────┼──────────────┐
            ▼              ▼              ▼
     ReserveInventory  ChargePayment  ShipOrder
            │              │              │
            ▼              ▼              ▼
         Success?       Success?       Done ✓
            │              │
         Yes │           No  │─── Compensate ──> ReleaseInventory
            │                                   ──> CancelOrder
            ▼
       Next step
• • •

VIStep Types: Pivot, Compensable, Retrievable

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.

• • •

VIIHow Rollback Works in Saga

Process: 1. Create order → 2. Reserve product → 3. Charge payment.

If step 3 (payment) fails, Saga initiates the "reverse dance":

  • Compensating step 2: Release product reservation (InventoryReservationCancelled).
  • Compensating step 1: Cancel order (OrderCancelled).

Compensating steps do not undo the fact of past actions (like ROLLBACK in SQL), but semantically invert their result by publishing new events.

• • •

VIIIIdempotency of Steps and Compensations

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.

Rules for Idempotency of Saga Steps

  1. Use 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.
  2. Upsert instead of insert. INSERT ... ON CONFLICT DO UPDATE.
  3. External calls — pass idempotency key in the header (Stripe, Adyen support this natively).
  4. Compensations must also be idempotent. ReleaseInventory for an already released reservation should return success, not an error.

Idempotency Window: How Long to Store?

-- Daily cleanup
DELETE FROM saga_idempotency
WHERE processed_at < NOW() - INTERVAL '30 days';
• • •

IXRetry Strategies and Circuit Breaker

Not every failure warrants compensation:

  • Transient failures (network, timeout, 503) — retry with backoff.
  • Business failures (insufficient funds, product not found) — 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");
}
• • •

XSemantic Lock and Its Cleanup

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:

  • Lost update: two sagas simultaneously reserve the same product — both see "available," one overwrites the other.
  • Dirty read: analytics service sees "order paid," but payment isn't confirmed yet.
  • Non-repeatable read: two reads of the same state during a saga yield different results.

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';

Cleanup of Stuck Semantic Locks

  1. TTL on lock. locked_at field + periodic cleanup job that releases locks older than N minutes.
  2. Orchestrator heartbeat. The orchestrator periodically updates heartbeat_at. If stale — the saga is considered dead, locks released.
  3. Explicit reconciliation job. A separate service scans active sagas and reconciles state with semantic 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);
• • •

XIDeadlines and Saga Timeouts

What to do if a saga step doesn't respond? How long to wait? How to decide what to compensate?

  1. Per-step timeout. Each step has its own timeout (e.g., 30 seconds for a payment gateway call).
  2. Saga-level deadline. The saga has an overall deadline (e.g., 5 minutes).
  3. Long-running sagas. Timers are persisted in the orchestrator.
// 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);
• • •

XIIOutbox in the Context of Saga

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:

  1. Updates business state (inventory.status = 'RESERVED').
  2. Writes the event to the outbox table (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;
Rule

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.

• • •

XIIICompensation Ordering

Reverse Order

Compensations execute in reverse order of steps. A → B → C, compensations — C → B → A. Classic approach for sagas with dependencies.

Parallel Compensations

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.

• • •

XIVProcess Manager and Event Sourcing

Process Manager is a pattern of a long-lived orchestrator that persistently stores its state and can recover after a failure.

Process Manager stores:

  • Current process state — which step, which steps completed.
  • Correlation ID — links all events of one saga.
  • History — result of each step for audit and recovery.
  • Timers — for timeouts.

Saga and Event Sourcing — A Natural Pair

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 }

Process Manager Example on Kafka Streams

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
• • •

XVSaga Versioning: Updating Without Losing Active Sagas

Versioned Workflows

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.

Backward-Compatible Steps

Adding new steps — at the end. Removing — via deprecation period. Modifying existing steps — only compatibly.

Rule

Never change the logic of an active saga in-place. Start a new version for new sagas, complete old ones on the old version.

• • •

XVIBusiness Compensations: When Technology Is Powerless

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:

  • Support receives notification.
  • Client is offered an alternative flight.
  • Bonus miles or voucher credited to the client's account.

Human-in-the-Loop

// Temporal: waiting for human decision
Workflow.await(Duration.ofHours(24),
    () -> humanDecisionReceived);

if (humanDecisionReceived) {
    activities.proceedWithAlternative(decision);
} else {
    activities.autoCompensate();
}

Other Business Compensation Cases

  • Chargeback: client disputes a transaction. Compensation — debit the seller's account in N days.
  • Partial rollback: 3 out of 10 order items cancelled.
  • Deferred payment: bank transfer arrives in 2 days. Saga waits for confirmation.
• • •

XVIIWorkflow Engines: Temporal, Camunda, Step Functions

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.

Saga Example on Temporal with Built-in Compensations

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;
        }
    }
}
• • •

XVIIIConcurrent Sagas and Conflict Resolution

  • Optimistic concurrency control. Each step checks data version. If changed — rollback and restart the saga.
  • Pessimistic locking (semantic lock). The "reserve" step immediately locks the product (status = PENDING).
  • Commutative operations. If operations are commutative, conflicts don't arise (e.g., adding items to a cart).
  • Saga ordering by priority. For financial sagas — ordering by timestamp or priority queue.
• • •

XIXBusiness Process Observability

  • Correlation ID — in all events of one saga.
  • Saga state store — a table/DB storing saga status by saga_id.
  • Dashboard — all active sagas, current step, age, status (running, compensating, completed, failed).
  • Alerting — saga stuck at one step longer than N minutes — alert.
  • Distributed tracing — OpenTelemetry trace per saga, span per step.

Monitoring Metrics

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
• • •

XXChecklists and Link to the Series

Link to Previous Chapters

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 Design Checklist

  • Coordination method chosen: choreography, orchestration, or workflow engine (decision tree)
  • Step types defined: compensable / pivot / retrievable
  • Compensation defined for each compensable step
  • All steps and compensations idempotent (saga_id + step_id as idempotency key)
  • Idempotency window defined (N days)
  • Retry strategy configured (exponential backoff + jitter)
  • Circuit breaker configured for external calls
  • Semantic locks used for isolation
  • TTL for semantic locks + cleanup job
  • Per-step and saga-level timeouts configured
  • Commands and events published via Transactional Outbox
  • Compensation ordering defined (reverse or parallel)
  • Process Manager persistently stores state
  • Saga versioning: new sagas on new version, old on old
  • Business compensations for cases where technical rollback is impossible
  • Human-in-the-loop via wait-for-signal (if needed)
  • Correlation ID in all saga events
  • Dashboard and alerts on stuck sagas
  • Metrics: duration, stuck count, compensation rate, pivot failures
Final Thesis

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.