Files
gh-giuseppe-trisciuoglio-de…/skills/spring-boot/spring-boot-saga-pattern/references/05-compensating-transactions.md
2025-11-29 18:28:30 +08:00

7.1 KiB

Compensating Transactions

Design Principles

Idempotency

Execute multiple times with same result:

public void cancelPayment(String paymentId) {
    Payment payment = paymentRepository.findById(paymentId)
        .orElse(null);

    if (payment == null) {
        // Already cancelled or doesn't exist
        return;
    }

    if (payment.getStatus() == PaymentStatus.CANCELLED) {
        // Already cancelled, idempotent
        return;
    }

    payment.setStatus(PaymentStatus.CANCELLED);
    paymentRepository.save(payment);

    // Refund logic here
}

Retryability

Design operations to handle retries without side effects:

@Retryable(
    value = {TransientException.class},
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000, multiplier = 2)
)
public void releaseInventory(String itemId, int quantity) {
    // Use set operations for idempotency
    InventoryItem item = inventoryRepository.findById(itemId)
        .orElseThrow();

    item.increaseAvailableQuantity(quantity);
    inventoryRepository.save(item);
}

Compensation Strategies

Backward Recovery

Undo completed steps in reverse order:

@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentFailedEvent event) {
    logger.error("Payment failed, initiating compensation");

    // Step 1: Cancel shipment preparation
    commandGateway.send(new CancelShipmentCommand(event.getOrderId()));

    // Step 2: Release inventory
    commandGateway.send(new ReleaseInventoryCommand(event.getOrderId()));

    // Step 3: Cancel order
    commandGateway.send(new CancelOrderCommand(event.getOrderId()));

    end();
}

Forward Recovery

Retry failed operation with exponential backoff:

@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentTransientFailureEvent event) {
    if (event.getRetryCount() < MAX_RETRIES) {
        // Retry payment with backoff
        ProcessPaymentCommand retryCommand = new ProcessPaymentCommand(
            event.getPaymentId(),
            event.getOrderId(),
            event.getAmount()
        );
        commandGateway.send(retryCommand);
    } else {
        // After max retries, compensate
        handlePaymentFailure(event);
    }
}

Semantic Lock Pattern

Prevent concurrent modifications during saga execution:

@Entity
public class Order {
    @Id
    private String orderId;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Version
    private Long version;

    private Instant lockedUntil;

    public boolean tryLock(Duration lockDuration) {
        if (isLocked()) {
            return false;
        }
        this.lockedUntil = Instant.now().plus(lockDuration);
        return true;
    }

    public boolean isLocked() {
        return lockedUntil != null &&
               Instant.now().isBefore(lockedUntil);
    }

    public void unlock() {
        this.lockedUntil = null;
    }
}

Compensation in Axon Framework

@Saga
public class OrderSaga {

    private String orderId;
    private String paymentId;
    private String inventoryId;
    private boolean compensating = false;

    @SagaEventHandler(associationProperty = "orderId")
    public void handle(InventoryReservationFailedEvent event) {
        logger.error("Inventory reservation failed");
        compensating = true;

        // Compensate: refund payment
        RefundPaymentCommand refundCommand = new RefundPaymentCommand(
            paymentId,
            event.getOrderId(),
            event.getReservedAmount(),
            "Inventory unavailable"
        );

        commandGateway.send(refundCommand);
    }

    @SagaEventHandler(associationProperty = "orderId")
    public void handle(PaymentRefundedEvent event) {
        if (!compensating) return;

        logger.info("Payment refunded, cancelling order");

        // Compensate: cancel order
        CancelOrderCommand command = new CancelOrderCommand(
            event.getOrderId(),
            "Inventory unavailable - payment refunded"
        );

        commandGateway.send(command);
    }

    @EndSaga
    @SagaEventHandler(associationProperty = "orderId")
    public void handle(OrderCancelledEvent event) {
        logger.info("Saga completed with compensation");
    }
}

Handling Compensation Failures

Handle cases where compensation itself fails:

@Service
public class CompensationService {

    private final DeadLetterQueueService dlqService;

    public void handleCompensationFailure(String sagaId, String step, Exception cause) {
        logger.error("Compensation failed for saga {} at step {}", sagaId, step, cause);

        // Send to dead letter queue for manual intervention
        dlqService.send(new FailedCompensation(
            sagaId,
            step,
            cause.getMessage(),
            Instant.now()
        ));

        // Create alert for operations team
        alertingService.alert(
            "Compensation Failure",
            "Saga " + sagaId + " failed compensation at " + step
        );
    }
}

Testing Compensation

Verify that compensation produces expected results:

@Test
void shouldCompensateWhenPaymentFails() {
    String orderId = "order-123";
    String paymentId = "payment-456";

    // Arrange: execute payment
    Payment payment = new Payment(paymentId, orderId, BigDecimal.TEN);
    paymentRepository.save(payment);
    orderRepository.save(new Order(orderId, OrderStatus.PENDING));

    // Act: compensate
    paymentService.cancelPayment(paymentId);

    // Assert: verify idempotency
    paymentService.cancelPayment(paymentId);

    Payment result = paymentRepository.findById(paymentId).orElseThrow();
    assertThat(result.getStatus()).isEqualTo(PaymentStatus.CANCELLED);
}

Common Compensation Patterns

Inventory Release

@Service
public class InventoryService {

    public void releaseInventory(String orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();

        order.getItems().forEach(item -> {
            InventoryItem inventoryItem = inventoryRepository
                .findById(item.getProductId())
                .orElseThrow();

            inventoryItem.increaseAvailableQuantity(item.getQuantity());
            inventoryRepository.save(inventoryItem);
        });
    }
}

Payment Refund

@Service
public class PaymentService {

    public void refundPayment(String paymentId) {
        Payment payment = paymentRepository.findById(paymentId)
            .orElseThrow();

        if (payment.getStatus() == PaymentStatus.PROCESSED) {
            payment.setStatus(PaymentStatus.REFUNDED);
            paymentGateway.refund(payment.getTransactionId());
            paymentRepository.save(payment);
        }
    }
}

Order Cancellation

@Service
public class OrderService {

    public void cancelOrder(String orderId, String reason) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow();

        order.setStatus(OrderStatus.CANCELLED);
        order.setCancellationReason(reason);
        order.setCancelledAt(Instant.now());

        orderRepository.save(order);
    }
}