Опирается на правила: R-DIST-SAGA-1R-DIST-SAGA-5 и R-DIST-SAGA-X1R-DIST-SAGA-X4 из Distributed Patterns Style Guide → раздел 2. Saga — оркестрация vs хореография.

Важно знать

  • Saga — это серия локальных транзакций + compensation, не один большой ACID-транзакшен через JTA/XA.
  • Orchestration (центральный координатор) — для complex sagas 4+ шагов или с branching. Видно весь flow в одном классе.
  • Choreography (события без координатора) — для simple sagas 2-3 шагов без branching. Меньше центральной сложности, но логика рассредоточена.
  • Saga state хранится в БД (saga_<name> таблица) — это даёт recovery после рестарта orchestrator-а, видимость in-flight саг и audit.
  • SagaId UUID проходит через каждое сообщение и каждый шаг — единственный способ корректно трассировать сагу через сервисы.
  • Запрет 2PC/XA через JTA — не работает с Kafka, не масштабируется, single point of failure.
  • Saga — отдельный orchestrator-компонент, не часть use case handler-а.

Saga — главный паттерн UCP для управления cross-service бизнес-операцией. Когда «создать заказ» = три сервиса, каждый со своей БД и своими транзакциями — sata собирает их в одну согласованную бизнес-операцию через локальные транзакции и compensation при сбое.

Когда применять Saga

R-DIST-SAGA-1: Saga применяется когда выполнены все три условия:

  1. Операция охватывает 2+ сервиса.
  2. Каждый шаг должен быть transactional локально (commit в свой PG).
  3. Нужна возможность compensation (rollback) при сбое промежуточного шага.

Если третье условие отсутствует (можно «дотолкать» сообщение retry-ями без отката предыдущих) — достаточно outbox + idempotent consumer, saga избыточна.

Orchestration — для complex sagas

R-DIST-SAGA-2: orchestration рекомендуется для саг 4+ шагов или с branching. Центральный координатор (OrderSagaOrchestrator) знает все шаги, условия переходов и compensation-цепочки.

@Component
@RequiredArgsConstructor
@Slf4j
public class OrderSagaOrchestrator {

    private final OrderService orderService;
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    private final SagaStateRepository sagaStateRepository;

    public void run(OrderRequest request) {
        var sagaId = UUID.randomUUID();
        sagaStateRepository.create(sagaId, "ORDER_CREATION", request);

        Long orderId = null;
        Long paymentId = null;
        try {
            sagaStateRepository.updateStep(sagaId, "CREATE_ORDER");
            orderId = orderService.create(sagaId, request);

            sagaStateRepository.updateStep(sagaId, "CHARGE_PAYMENT");
            paymentId = paymentService.charge(sagaId, orderId, request.amount());

            sagaStateRepository.updateStep(sagaId, "RESERVE_INVENTORY");
            inventoryService.reserve(sagaId, orderId, request.items());

            sagaStateRepository.updateStep(sagaId, "CONFIRM_ORDER");
            orderService.confirm(sagaId, orderId);

            sagaStateRepository.complete(sagaId);
        } catch (Exception e) {
            log.error("Saga {} failed at step, compensating", sagaId, e);
            compensate(sagaId, orderId, paymentId);
            throw new SagaFailedException(sagaId, e);
        }
    }

    private void compensate(UUID sagaId, Long orderId, Long paymentId) {
        sagaStateRepository.updateStatus(sagaId, "COMPENSATING");
        if (paymentId != null) {
            paymentService.refund(sagaId, paymentId);
        }
        if (orderId != null) {
            orderService.cancel(sagaId, orderId);
        }
        sagaStateRepository.updateStatus(sagaId, "FAILED");
    }
}

Плюсы orchestration: весь flow читается в одном файле, легко добавить новый шаг, легко увидеть compensation-логику. Минусы: координатор — точка отказа (но recovery через saga_<name> таблицу).

Choreography — для simple sagas

R-DIST-SAGA-3: choreography — для 2-3 шагов без branching. Каждый сервис подписан на события других и реагирует. Центрального координатора нет.

order.created    → payment-service charges     → payment.charged → order-service confirms
                                               ↘ payment.failed  → order-service cancels
@Component
@RequiredArgsConstructor
public class PaymentChargedListener {

    private final OrderRepository orderRepository;

    @KafkaListener(topics = "payment.events", groupId = "order-service")
    @Transactional
    public void onPaymentCharged(PaymentChargedEvent event) {
        var order = orderRepository.findBySagaId(event.sagaId())
            .orElseThrow();
        order.confirm();
        orderRepository.save(order);
    }
}

@Component
@RequiredArgsConstructor
public class PaymentFailedListener {

    private final OrderRepository orderRepository;

    @KafkaListener(topics = "payment.events", groupId = "order-service")
    @Transactional
    public void onPaymentFailed(PaymentFailedEvent event) {
        var order = orderRepository.findBySagaId(event.sagaId())
            .orElseThrow();
        order.cancel();
        orderRepository.save(order);
    }
}

Плюсы: нет центрального координатора, каждый сервис автономен. Минусы: при 4+ шагах flow становится невозможно прочитать — нужно открывать N сервисов и собирать картину в голове.

ПараметрOrchestrationChoreography
Шагов4+2-3
Branchingданет
Видимость flowодин классN сервисов
Где stateу orchestrator-ау каждого сервиса
Сложность реализациисредняянизкая (на старте)
Сложность отладкисредняявысокая (при росте)

Saga state в БД

R-DIST-SAGA-4: state саги хранится в БД (saga_<name> таблица). Это даёт три критичных свойства:

CREATE TABLE saga_order_creation (
    saga_id       uuid PRIMARY KEY,
    status        text NOT NULL,        -- IN_PROGRESS, COMPLETED, FAILED, COMPENSATING
    current_step  text NOT NULL,
    payload       jsonb NOT NULL,
    started_at    timestamptz NOT NULL,
    completed_at  timestamptz,
    last_error    text
);
CREATE INDEX ix_saga_order_creation_status ON saga_order_creation(status)
    WHERE status IN ('IN_PROGRESS', 'COMPENSATING');
  1. Видимость. SELECT * FROM saga_order_creation WHERE status = 'IN_PROGRESS' — какие саги сейчас в процессе. Без этого operations слепые.
  2. Recovery. Если orchestrator упал на середине шага 3, после рестарта читаем все IN_PROGRESS саги и продолжаем. Без state-таблицы все in-flight саги теряются.
  3. Audit. История каждой саги остаётся в БД — кто, когда, какой шаг сломал, на чём compensation-ил.

Partial index WHERE status IN ('IN_PROGRESS', 'COMPENSATING') — потому что 99% rows быстро становятся COMPLETED, искать нужно только активные.

SagaId сквозной

R-DIST-SAGA-5: sagaId (UUID) проходит через каждое сообщение, каждый HTTP-запрос между сервисами, каждое доменное событие. Это связывает все шаги в одну сагу для tracing, debugging и idempotency.

// HTTP header
POST /orders
X-Saga-Id: 0193a8f3-7c21-7e3f-9b4a-...

// Kafka message
{
  "sagaId": "0193a8f3-7c21-7e3f-9b4a-...",
  "eventId": "0193a8f3-8c11-7f1e-...",
  "eventType": "OrderCreated.v1",
  "payload": { ... }
}

В таблицах сервисов — колонка saga_id с индексом. Это даёт SELECT * FROM orders WHERE saga_id = ? — что произошло в этой саге в моём сервисе.

Что запрещено

2PC/XA вместо саги

R-DIST-SAGA-X1: distributed transaction через JTA/XA не подходит для нашего стека. Подробнее — Distributed transactions.

Saga без compensation

R-DIST-SAGA-X2: если шаг 3 упал, а шаги 1 и 2 уже committed — без compensation остаётся «полусделанная» операция в проде. Деньги списаны, товар не зарезервирован, заказ создан в статусе PROCESSING навсегда.

Каждый шаг саги обязан иметь парную compensation-команду. Подробнее — Compensation.

Saga state только in-memory

R-DIST-SAGA-X3: orchestrator хранит state в Map<UUID, SagaState> — при рестарте все in-flight саги теряются. Запросы клиента «что с моим заказом» висят навсегда, потому что никто не знает, что сага существовала.

State обязан быть в БД — другой способ переживания рестарта не работает.

Saga смешана с use case в одном handler-е

R-DIST-SAGA-X4: handler use case-а CreateOrderHandler не должен сам вызывать payment-service и inventory-service. Это размывает ответственность: handler пишет в БД и инициирует cross-service flow одновременно.

Корректно: handler пишет в БД свой локальный шаг → публикует событие/команду → отдельный OrderSagaOrchestrator (компонент) ведёт сагу.

// ПЛОХО — saga встроена в handler use case-а
@UseCase
public class CreateOrderHandler implements UseCaseHandler<CreateOrderCommand, Order> {
    @Transactional
    public Order handle(CreateOrderCommand command) {
        var order = orderRepository.save(new Order(command));
        var paymentId = paymentClient.charge(order.id(), command.amount());   // HTTP внутри @Transactional!
        try {
            inventoryClient.reserve(order.id(), command.items());
        } catch (Exception e) {
            paymentClient.refund(paymentId);
            throw e;
        }
        return order;
    }
}

// ХОРОШО — handler делает только локальный шаг, orchestrator — отдельно
@UseCase
public class CreateOrderHandler implements UseCaseHandler<CreateOrderCommand, Order> {
    @Transactional
    public Order handle(CreateOrderCommand command) {
        var order = orderRepository.save(Order.start(command));
        outboxEventPublisher.publish(new OrderStartedEvent(order.sagaId(), order.id()));
        return order;
    }
}

@Component
public class OrderSagaOrchestrator {
    @KafkaListener(topics = "order.events")
    public void onOrderStarted(OrderStartedEvent event) { ... }
}

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
2PC/XA через JTA вместо сагиR-DIST-SAGA-X1saga с локальными транзакциями
Saga без compensation-командR-DIST-SAGA-X2каждый шаг имеет compensation
Saga state только in-memoryR-DIST-SAGA-X3saga_<name> таблица в PG
Saga смешана с use caseR-DIST-SAGA-X4отдельный orchestrator
HTTP-вызовы внутри @Transactional write-handler-аR-DIST-SAGA-X4outbox + событие → orchestrator
Choreography на 5+ шаговR-DIST-SAGA-2orchestration с координатором

Куда дальше

  • Distributed Patterns → раздел 2. Saga — нормативные формулировки.
  • Compensation — semantic state-change, не DELETE; идемпотентность compensation.
  • Idempotency — каждый шаг саги обязан быть идемпотентным.
  • Outbox + Inbox — публикация шагов и событий саги.
  • Eventual consistency — read-your-writes для in-flight саги.
  • Distributed transactions — почему 2PC/XA не вариант.
  • Resilience → retry — retry только при идемпотентности шагов саги.