Опирается на правила: R-DIST-COMP-1R-DIST-COMP-4 и R-DIST-COMP-X1R-DIST-COMP-X3 из Distributed Patterns Style Guide → раздел 6. Compensation.

Важно знать

  • Compensation — отмена эффекта предыдущего шага саги, не технический rollback.
  • Каждая command, участвующая в саге, имеет парную compensation-команду: chargePaymentrefundPayment, reserveInventoryreleaseInventory, createOrdercancelOrder.
  • Compensation идемпотентна — saga может повторить её при retry; повторный refund одной и той же транзакции = no-op.
  • Semantic, не технический. Refund — новая транзакция в БД и у банка, не «откат» оригинала. Деньги уже ушли, их возвращают, не «не списывают задним числом».
  • Audit trail обязателен — status REFUNDED со ссылкой на исходный платёж. DELETE теряет историю.
  • Failure compensation — отдельный сценарий: refund сам упал, нужен DLQ + manual review, не «потерять и забыть».
  • Saga без compensation — главный антипаттерн, оставляющий «полусделанные» операции в проде.

Сага без compensation — это не сага, а оптимистичная цепочка. Когда payment-service отвечает «ок» на 99.9% запросов, легко забыть про 0.1%: остальные шаги идут дальше, а потом payment понадобится откатить. Compensation — единственный способ корректно завершить in-flight сагу при сбое.

Парная compensation-команда

R-DIST-COMP-1: каждая command, меняющая state, имеет compensation-команду в том же сервисе.

Forward commandCompensation command
chargePayment(orderId, amount, idempotencyKey)refundPayment(paymentId, reason)
reserveInventory(orderId, items)releaseInventory(orderId)
createOrder(customerId, items)cancelOrder(orderId, reason) или markOrderFailed(orderId)
assignDeliverySlot(orderId, slot)freeDeliverySlot(slot)
applyDiscountCoupon(couponId)releaseDiscountCoupon(couponId)

Compensation — отдельный use case, отдельный handler, отдельный endpoint у сервиса. Orchestrator саги вызывает его, как любую другую команду.

@UseCase
@RequiredArgsConstructor
public class RefundPaymentHandler implements UseCaseHandler<RefundPaymentCommand, Payment> {

    private final PaymentRepository paymentRepository;
    private final PaymentProviderPort paymentProvider;
    private final OutboxEventPublisher outboxEventPublisher;

    @Override
    @Transactional
    public Payment handle(RefundPaymentCommand command) {
        var payment = paymentRepository.findById(command.paymentId(), SelectMode.FOR_UPDATE)
            .orElseThrow(() -> new PaymentNotFoundException(command.paymentId()));

        if (payment.status() == PaymentStatus.REFUNDED) {
            return payment;
        }
        if (payment.status() != PaymentStatus.CHARGED) {
            throw new PaymentNotRefundableException(payment.id(), payment.status());
        }

        var refundId = paymentProvider.refund(payment.externalId(), payment.amount());
        payment.markRefunded(refundId, command.reason());
        paymentRepository.save(payment);

        outboxEventPublisher.publish(new PaymentRefundedEvent(payment.id(), refundId));
        return payment;
    }
}

Идемпотентность compensation

R-DIST-COMP-2: orchestrator саги может вызвать refund несколько раз (retry на network timeout, рестарт после крэша). Compensation handler обязан возвращать тот же результат при повторе.

В примере выше — проверка if (payment.status() == REFUNDED) return payment делает повторный refund no-op. Плюс paymentProvider.refund сам идемпотентен через Idempotency-Key (см. Idempotency).

Без идемпотентности — orchestrator повторяет refund, банк делает refund дважды, клиент получает удвоенный возврат, потом support неделю разбирается.

Semantic compensation, не технический rollback

R-DIST-COMP-3: payment compensation = refund (новая транзакция), не «откат» (ничего не откатывается, деньги уже у банка).

Forward:  chargePayment    → CHARGED   → деньги у банка
Compensation: refundPayment → REFUNDED  → деньги вернулись клиенту

В БД — две записи:

  • payment(id=42, status=REFUNDED, external_id=..., amount=1000)
  • refund(id=7, payment_id=42, external_refund_id=..., amount=1000, reason=...)

Не одна запись с status=CHARGED → DELETE. Деньги реально двигались туда и обратно, в БД должно быть две операции, обе видны в audit.

Аналогично для inventory:

Forward:  reserveInventory  → INSERT reservation(status=ACTIVE)
Compensation: releaseInventory → UPDATE reservation SET status=RELEASED, released_at=...

Не DELETE FROM reservation — потому что нужно знать, что было резервирование, когда оно отменилось и почему.

Audit trail обязателен

R-DIST-COMP-4: compensation оставляет audit trail — статус смены с reference к исходной операции, не потеря данных.

SELECT id, status, refund_id, refunded_at, refund_reason
FROM payment
WHERE order_id = 12345;

-- id  | status   | refund_id | refunded_at         | refund_reason
-- 42  | REFUNDED | 7         | 2026-05-25 12:30:00 | INVENTORY_UNAVAILABLE

Это даёт ответы на «почему клиент получил возврат», «какая сага инициировала refund», «кто триггерил compensation». Без audit trail каждый инцидент — расследование через логи Kafka и сервисов.

Failure compensation — DLQ

Compensation сам может упасть. Refund-эндпоинт payment-провайдера лёг, network partition, что-то ещё. Простая retry в саге решает большинство случаев, но не все.

private void compensate(UUID sagaId, Long orderId, Long paymentId) {
    sagaStateRepository.updateStatus(sagaId, "COMPENSATING");
    try {
        if (paymentId != null) {
            paymentService.refund(sagaId, paymentId);
        }
        if (orderId != null) {
            orderService.cancel(sagaId, orderId);
        }
        sagaStateRepository.updateStatus(sagaId, "FAILED");
    } catch (Exception e) {
        log.error("Compensation failed for saga {}", sagaId, e);
        sagaStateRepository.updateStatus(sagaId, "COMPENSATION_FAILED");
        compensationDlqRepository.enqueue(sagaId, e.getMessage());
        alertingService.notifyOpsTeam(sagaId, e);
    }
}

COMPENSATION_FAILED — терминальный статус саги, требующий manual review. Деньги «висят», support связывается с клиентом, ops чинит руками. Альтернатива — потерять и забыть — недопустима для денег.

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

Saga без compensation

R-DIST-COMP-X1: см. R-DIST-SAGA-X2. Полусделанная операция в проде после сбоя шага N — главный источник «зависших» заказов.

DELETE как compensation

R-DIST-COMP-X2: после createOrder compensation — не DELETE FROM orders WHERE id = ?, а UPDATE orders SET status = 'CANCELLED', cancelled_at = ..., cancel_reason = ....

Почему важно:

  • Audit теряется. История заказа — нет.
  • FK violations. Если на orders ссылаются order_items, payment, shipment — DELETE уронит каскад или constraint violation.
  • Refund к зомби-заказу. Платёж уже был, ссылается на orderId; после DELETE orders ID 12345 → платёж висит без заказа.
-- ПЛОХО
DELETE FROM orders WHERE id = 12345;

-- ХОРОШО
UPDATE orders
SET status = 'CANCELLED', cancelled_at = now(), cancel_reason = $1
WHERE id = 12345;

Compensation без DLQ при failure

R-DIST-COMP-X3: refund упал, в логе error, saga state остался COMPENSATING навсегда. Деньги «висят», никто не знает. Дни идут, клиент жалуется.

Обязательно:

  • Терминальный статус COMPENSATION_FAILED (не COMPENSATING навечно).
  • Запись в DLQ (отдельная таблица или Kafka DLQ topic).
  • Алерт ops-команде.

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

АнтипаттернПравилоЧто взамен
Saga без compensation-командR-DIST-COMP-X1парная compensation для каждого шага
DELETE FROM orders как compensationR-DIST-COMP-X2UPDATE status = 'CANCELLED'
Compensation не идемпотентнаR-DIST-COMP-2проверка status перед повтором + Idempotency-Key
Технический rollback вместо semanticR-DIST-COMP-3refund — новая транзакция, не «откат» оригинала
Compensation без audit trailR-DIST-COMP-4status REFUNDED + refund_id + refunded_at + reason
Failure compensation → log.error и забытьR-DIST-COMP-X3COMPENSATION_FAILED + DLQ + алерт
Compensation в том же handler что forwardR-DIST-SAGA-X4отдельный use case, отдельный handler

Куда дальше