Опирается на правила:
R-DIST-COMP-1…R-DIST-COMP-4иR-DIST-COMP-X1…R-DIST-COMP-X3из Distributed Patterns Style Guide → раздел 6. Compensation.
Важно знать
- Compensation — отмена эффекта предыдущего шага саги, не технический rollback.
- Каждая command, участвующая в саге, имеет парную compensation-команду:
chargePayment↔refundPayment,reserveInventory↔releaseInventory,createOrder↔cancelOrder.- 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 command | Compensation 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 как compensation | R-DIST-COMP-X2 | UPDATE status = 'CANCELLED' |
| Compensation не идемпотентна | R-DIST-COMP-2 | проверка status перед повтором + Idempotency-Key |
| Технический rollback вместо semantic | R-DIST-COMP-3 | refund — новая транзакция, не «откат» оригинала |
| Compensation без audit trail | R-DIST-COMP-4 | status REFUNDED + refund_id + refunded_at + reason |
| Failure compensation → log.error и забыть | R-DIST-COMP-X3 | COMPENSATION_FAILED + DLQ + алерт |
| Compensation в том же handler что forward | R-DIST-SAGA-X4 | отдельный use case, отдельный handler |
Куда дальше
- Distributed Patterns → раздел 6. Compensation — нормативные формулировки.
- Saga — где вызывается compensation в orchestrator-е.
- Idempotency — compensation идемпотентна через Idempotency-Key и status-check.
- Outbox + Inbox — события compensation публикуются через outbox.
- Kafka → retry topic + DLQ — DLQ-topic для compensation failures.
- Error handling → exception hierarchy —
PaymentNotRefundableExceptionи family.