DEV Community

Pedro Santos
Pedro Santos

Posted on

Rollback Chains: When Payment Fails, What Actually Happens

Rollback Chains: When Payment Fails, What Actually Happens

In the previous post, I showed the orchestrator's state transition table. It knows which topic to publish on failure. But what happens on the receiving end? What does "rollback" actually look like in code?

This post walks through three real failure scenarios in my saga system. Each one triggers a different rollback chain, and each service handles compensation differently.

How Compensation Works

Every service implements two operations: the forward action and its compensation.

Service Forward Compensation
product-validation validateExistingProducts() rollbackEvent()
payment-service realizePayment() realizeRefund()
inventory-service updateInventory() rollbackInventory()

The forward action does the work and publishes SUCCESS or ROLLBACK to the orchestrator. The compensation undoes the work and publishes FAIL.

There's an important distinction between ROLLBACK and FAIL. ROLLBACK means "I failed, and I need my own compensation first." FAIL means "I already rolled back, now the previous service needs to compensate." The orchestrator uses this to chain rollbacks in the correct order.

Scenario 1: Inventory Fails (Full Rollback Chain)

This is the worst case. Product validation passed. Payment was charged. Then inventory fails because the product is out of stock.

The rollback chain goes: Inventory → Payment → Product Validation → Finish Fail.

Step 1: Inventory Detects the Problem

public void updateInventory(Event event) {
    try {
        checkCurrentValidation(event);
        createOrderInventory(event);
        updateInventory(event.getOrder());
        handleSuccess(event);
    } catch (Exception ex) {
        log.error("Error trying to update inventory: ", ex);
        handleFailCurrentNotExecuted(event, ex.getMessage());
    }
    producer.sendEvent(jsonUtil.toJson(event).orElseThrow());
}

private void checkInventory(int available, int orderQuantity) {
    if (orderQuantity > available) {
        throw new ValidationException("Product is out of stock!");
    }
}
Enter fullscreen mode Exit fullscreen mode

When stock is insufficient, checkInventory throws. The catch block calls handleFailCurrentNotExecuted, which sets status to ROLLBACK. The event goes back to the orchestrator with source=INVENTORY_SERVICE, status=ROLLBACK.

private void handleFailCurrentNotExecuted(Event event, String message) {
    event.setStatus(ROLLBACK);
    event.setSource(CURRENT_SOURCE);
    addHistory(event, "Fail to update inventory: ".concat(message));
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Orchestrator Routes the Rollback

The orchestrator looks up the table: INVENTORY_SERVICE + ROLLBACK → INVENTORY_FAIL. It publishes to inventory-fail.

Step 3: Inventory Compensates Itself

The inventory-service consumes from inventory-fail and restores the previous quantities:

public void rollbackInventory(Event event) {
    event.setStatus(FAIL);
    event.setSource(CURRENT_SOURCE);
    try {
        returnInventoryToPreviousValues(event);
        addHistory(event, "Rollback executed for inventory!");
    } catch (Exception ex) {
        addHistory(event, "Rollback not executed for inventory: ".concat(ex.getMessage()));
    }
    producer.sendEvent(jsonUtil.toJson(event).orElseThrow());
}

private void returnInventoryToPreviousValues(Event event) {
    orderInventoryRepository
        .findByOrderIdAndTransactionId(event.getOrder().getOrderId(), event.getTransactionId())
        .forEach(orderInventory -> {
            var inventory = orderInventory.getInventory();
            inventory.setAvailable(orderInventory.getOldQuantity());
            inventoryRepository.save(inventory);
        });
}
Enter fullscreen mode Exit fullscreen mode

Notice that it uses oldQuantity from the OrderInventory record. When the forward action runs, it saves both old and new quantities. The rollback reads the old value and restores it. No guessing.

After rollback, inventory publishes FAIL back to the orchestrator. Now the orchestrator sees INVENTORY_SERVICE + FAIL → PAYMENT_FAIL and publishes to payment-fail.

Step 4: Payment Refunds

public void realizeRefund(Event event) {
    event.setStatus(FAIL);
    event.setSource(CURRENT_SOURCE);
    try {
        changePaymentStatusToRefund(event);
        addHistory(event, "Rollback executed for payment!");
    } catch (Exception ex) {
        addHistory(event, "Rollback not executed for payment: ".concat(ex.getMessage()));
    }
    producer.sendEvent(jsonUtil.toJson(event).orElseThrow());
}

private void changePaymentStatusToRefund(Event event) {
    var payment = findByOrderIdAndTransactionId(event);
    payment.setStatus(PaymentStatus.REFUND);
    setEventAmountItems(event, payment);
    save(payment);
}
Enter fullscreen mode Exit fullscreen mode

Payment changes the status to REFUND and publishes FAIL. The orchestrator sees PAYMENT_SERVICE + FAIL → PRODUCT_VALIDATION_FAIL and continues the chain.

Step 5: Product Validation Rolls Back

public void rollbackEvent(Event event) {
    changeValidationToFail(event);
    event.setStatus(FAIL);
    event.setSource(CURRENT_SOURCE);
    addHistory(event, "Rollback executed on product validation!");
    producer.sendEvent(jsonUtil.toJson(event).orElseThrow());
}

private void changeValidationToFail(Event event) {
    validationRepository
        .findByOrderIdAndTransactionId(event.getOrderId(), event.getTransactionId())
        .ifPresentOrElse(
            validation -> {
                validation.setSuccess(false);
                validationRepository.save(validation);
            },
            () -> createValidation(event, false));
}
Enter fullscreen mode Exit fullscreen mode

Finally, the orchestrator sees PRODUCT_VALIDATION_SERVICE + FAIL → FINISH_FAIL and the saga ends.

Scenario 2: Payment Fails (Partial Rollback)

Payment fails (card declined, fraud blocked). Inventory was never touched, so there's nothing to roll back there. Only product validation needs compensation.

Payment ROLLBACK → payment-fail → Payment refunds → FAIL
→ product-validation-fail → Validation marks as failed → FAIL
→ finish-fail
Enter fullscreen mode Exit fullscreen mode

Shorter chain. Same mechanism.

Scenario 3: Product Validation Fails (No Rollback Needed)

Product validation is the first step. If the product doesn't exist in the catalog, nothing was charged and nothing was reserved. The orchestrator sees PRODUCT_VALIDATION_SERVICE + FAIL → FINISH_FAIL and skips straight to the end.

The Pattern: Save Before You Change

Every service follows the same pattern for safe rollbacks. Before changing any data, save the original values.

The inventory-service saves oldQuantity and newQuantity in OrderInventory:

private OrderInventory createOrderInventory(Event event,
                                            OrderProducts product,
                                            Inventory inventory) {
    return OrderInventory.builder()
        .inventory(inventory)
        .oldQuantity(inventory.getAvailable())    // save current
        .orderQuantity(product.getQuantity())
        .newQuantity(inventory.getAvailable() - product.getQuantity())  // save new
        .orderId(event.getOrder().getOrderId())
        .transactionId(event.getTransactionId())
        .build();
}
Enter fullscreen mode Exit fullscreen mode

The payment-service saves the payment with status PENDING before processing:

private void createPendingPayment(Event event) {
    var payment = Payment.builder()
        .orderId(event.getOrder().getOrderId())
        .transactionId(event.getTransactionId())
        .totalAmount(totalAmount)
        .totalItems(totalItems)
        .build();
    save(payment);  // status defaults to PENDING via @PrePersist
}
Enter fullscreen mode Exit fullscreen mode

This way, the compensation logic always has the data it needs to undo the change.

Idempotency Guards

Each service checks for duplicate transactions before processing:

private void checkCurrentValidation(Event event) {
    if (orderInventoryRepository.existsByOrderIdAndTransactionId(
            event.getOrder().getOrderId(), event.getTransactionId())) {
        throw new ValidationException("There's another transactionId for this validation.");
    }
}
Enter fullscreen mode Exit fullscreen mode

If Kafka delivers the same message twice (at-least-once delivery), the service detects the duplicate and skips the forward action. Without this check, you'd charge the customer twice or double-deduct inventory.

What's Next

The rollback logic is clean when you read the code, but things get tricky in practice. What if the refund fails? What if Kafka drops a message? In the next post, I'll cover how I test these scenarios with unit tests and real failure injection.

The repo: github.com/pedrop3/saga-orchestration

Top comments (0)