Skip to main content

Example: Order Fulfillment

A complete saga example for e-commerce order fulfillment — from inventory reservation to customer notification.


The Problem

Processing an order involves four steps that must all succeed, and each one touches a different external system:

1. reserve-inventory → Reserve the items from stock
2. charge-payment → Charge the customer's card
3. schedule-shipping → Create shipping label and schedule pickup
4. notify-customer → Send confirmation email/SMS

The challenge is what happens when each step fails:

Failure at stepWhat must be undone
charge-payment failsRelease the inventory reservation
schedule-shipping failsRefund the payment + release inventory
notify-customer failsRetry forever (notification must succeed)

Once shipping is scheduled, it becomes the PIVOT — the point of no return. The shipping label has been created and the carrier has been notified; you can't "unschedule" that by calling an API.


Context

public record OrderContext(
// Input data
String orderId,
String customerId,
List<OrderItem> items,
BigDecimal totalAmount,
String paymentMethodId,
Address shippingAddress,

// Step outputs
String inventoryReservationId,
String paymentChargeId,
String shippingLabelId,
String trackingNumber
) {
public static OrderContext create(Order order) {
return new OrderContext(
order.getId(),
order.getCustomerId(),
List.copyOf(order.getItems()),
order.getTotal(),
order.getPaymentMethodId(),
order.getShippingAddress(),
null, null, null, null
);
}

public OrderContext withInventoryReservationId(String id) {
return new OrderContext(orderId, customerId, items, totalAmount,
paymentMethodId, shippingAddress, id, paymentChargeId,
shippingLabelId, trackingNumber);
}

public OrderContext withPaymentChargeId(String id) {
return new OrderContext(orderId, customerId, items, totalAmount,
paymentMethodId, shippingAddress, inventoryReservationId, id,
shippingLabelId, trackingNumber);
}

public OrderContext withShipping(String labelId, String tracking) {
return new OrderContext(orderId, customerId, items, totalAmount,
paymentMethodId, shippingAddress, inventoryReservationId,
paymentChargeId, labelId, tracking);
}
}

Saga Definition

@Saga(
value = "order-fulfillment",
description = "End-to-end order fulfillment with automatic compensation"
)
public class OrderFulfillmentSaga implements SagaDefinition<OrderContext> {

private final InventoryService inventoryService;
private final PaymentService paymentService;
private final ShippingService shippingService;
private final NotificationService notificationService;

public OrderFulfillmentSaga(
InventoryService inventoryService,
PaymentService paymentService,
ShippingService shippingService,
NotificationService notificationService
) {
this.inventoryService = inventoryService;
this.paymentService = paymentService;
this.shippingService = shippingService;
this.notificationService = notificationService;
}

@Override
public void define(SagaBuilder<OrderContext> builder) {
builder
// Step 1: Reserve inventory (COMPENSABLE)
.step("reserve-inventory")
.invoke(this::reserveInventory)
.compensate(this::releaseInventory)
.timeout(Duration.ofSeconds(10))

// Step 2: Charge payment (COMPENSABLE)
.step("charge-payment")
.invoke(this::chargePayment)
.compensate(this::refundPayment)
.retry(exponential(3, Duration.ofSeconds(2)))
.timeout(Duration.ofSeconds(30))

// Step 3: Schedule shipping (PIVOT — no compensation)
.step("schedule-shipping")
.invoke(this::scheduleShipping)
.retry(exponential(3, Duration.ofSeconds(1)))
.timeout(Duration.ofSeconds(15))

// Step 4: Notify customer (RETRIABLE — must always succeed)
.step("notify-customer")
.invoke(this::notifyCustomer)
.retry(infinite()
.initialDelay(Duration.ofMinutes(1))
.maxDelay(Duration.ofHours(2)))

.build();
}

// ═══════════════════════════════════════════════════════════════
// INVOKE METHODS
// ═══════════════════════════════════════════════════════════════

private OrderContext reserveInventory(OrderContext ctx) {
log.info("Reserving inventory for order: {}", ctx.orderId());

String reservationId = inventoryService.reserve(
ctx.orderId(),
ctx.items()
);

return ctx.withInventoryReservationId(reservationId);
}

private OrderContext chargePayment(OrderContext ctx) {
log.info("Charging payment for order: {}, amount: {}",
ctx.orderId(), ctx.totalAmount());

PaymentCharge charge = paymentService.charge(
ctx.paymentMethodId(),
ctx.totalAmount(),
ctx.orderId() // Used as idempotency key
);

return ctx.withPaymentChargeId(charge.getId());
}

private OrderContext scheduleShipping(OrderContext ctx) {
log.info("Scheduling shipping for order: {}", ctx.orderId());

ShippingLabel label = shippingService.createLabel(
ctx.orderId(),
ctx.items(),
ctx.shippingAddress()
);

return ctx.withShipping(label.getId(), label.getTrackingNumber());
}

private OrderContext notifyCustomer(OrderContext ctx) {
log.info("Notifying customer: {} for order: {}",
ctx.customerId(), ctx.orderId());

notificationService.sendOrderConfirmation(
ctx.customerId(),
ctx.orderId(),
ctx.trackingNumber()
);

return ctx;
}

// ═══════════════════════════════════════════════════════════════
// COMPENSATE METHODS
// ═══════════════════════════════════════════════════════════════

private void releaseInventory(OrderContext ctx) {
log.info("Compensating: releasing inventory reservation: {}",
ctx.inventoryReservationId());

if (ctx.inventoryReservationId() != null) {
inventoryService.release(ctx.inventoryReservationId());
}
}

private void refundPayment(OrderContext ctx) {
log.info("Compensating: refunding charge: {}", ctx.paymentChargeId());

if (ctx.paymentChargeId() != null) {
paymentService.refund(ctx.paymentChargeId());
}
}
}

Configuration

sagaweaw:
enabled: true
observability:
enabled: true
token: ${SAGAWEAW_TOKEN}
retry:
default-policy: exponential
max-attempts: 3
@RestController
@RequestMapping("/api/orders")
public class OrderController {

private final SagaManager sagaManager;
private final OrderRepository orderRepository;

@PostMapping("/{orderId}/fulfill")
public ResponseEntity<FulfillmentResponse> fulfillOrder(
@PathVariable String orderId,
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey
) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));

OrderContext context = OrderContext.create(order);

SagaExecution execution = sagaManager.start(
OrderFulfillmentSaga.class,
context,
idempotencyKey != null
? IdempotencyKey.of(idempotencyKey)
: IdempotencyKey.of("fulfill:" + orderId)
);

return ResponseEntity.accepted().body(
new FulfillmentResponse(execution.sagaId(), orderId)
);
}
}

What Failure Looks Like

If payment fails after inventory is reserved, GET /api/sagas/{id} will show:

{
"name": "order-fulfillment",
"status": { "type": "COMPENSATED" },
"steps": [
{
"name": "reserve-inventory",
"status": { "type": "COMPENSATED" },
"durationMs": 45
},
{
"name": "charge-payment",
"status": { "type": "FAILED" },
"attempt": 3,
"maxAttempts": 3,
"lastError": "Card declined: insufficient funds"
}
]
}

The inventory reservation was automatically released. The customer is not charged. No manual cleanup required.