Exemplo: Fulfillment de Pedido
Um exemplo completo de saga para fulfillment de pedidos de e-commerce — desde a reserva de inventário até a notificação do cliente.
O Problema
Processar um pedido envolve quatro etapas que precisam todas ter sucesso, e cada uma toca um sistema externo diferente:
1. reserve-inventory → Reservar os itens do estoque
2. charge-payment → Cobrar o cartão do cliente
3. schedule-shipping → Criar etiqueta de envio e agendar coleta
4. notify-customer → Enviar email/SMS de confirmação
O desafio é o que acontece quando cada etapa falha:
| Falha na etapa | O que deve ser desfeito |
|---|---|
charge-payment falha | Liberar a reserva de inventário |
schedule-shipping falha | Reembolsar o pagamento + liberar inventário |
notify-customer falha | Tentar novamente indefinidamente (notificação deve ter sucesso) |
Uma vez que o envio é agendado, ele se torna o PIVOT — o ponto de não retorno. A etiqueta de envio foi criada e a transportadora foi notificada; você não pode "desagendar" isso chamando uma API.
Context
public record OrderContext(
// Dados de entrada
String orderId,
String customerId,
List<OrderItem> items,
BigDecimal totalAmount,
String paymentMethodId,
Address shippingAddress,
// Outputs dos steps
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);
}
}
Definição da Saga
@Saga(
value = "order-fulfillment",
description = "Fulfillment completo de pedido com compensação automática"
)
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: Reservar inventário (COMPENSABLE)
.step("reserve-inventory")
.invoke(this::reserveInventory)
.compensate(this::releaseInventory)
.timeout(Duration.ofSeconds(10))
// Step 2: Cobrar pagamento (COMPENSABLE)
.step("charge-payment")
.invoke(this::chargePayment)
.compensate(this::refundPayment)
.retry(exponential(3, Duration.ofSeconds(2)))
.timeout(Duration.ofSeconds(30))
// Step 3: Agendar envio (PIVOT — sem compensação)
.step("schedule-shipping")
.invoke(this::scheduleShipping)
.retry(exponential(3, Duration.ofSeconds(1)))
.timeout(Duration.ofSeconds(15))
// Step 4: Notificar cliente (RETRIABLE — deve sempre ter sucesso)
.step("notify-customer")
.invoke(this::notifyCustomer)
.retry(infinite()
.initialDelay(Duration.ofMinutes(1))
.maxDelay(Duration.ofHours(2)))
.build();
}
// ═══════════════════════════════════════════════════════════════
// MÉTODOS INVOKE
// ═══════════════════════════════════════════════════════════════
private OrderContext reserveInventory(OrderContext ctx) {
log.info("Reservando inventário para pedido: {}", ctx.orderId());
String reservationId = inventoryService.reserve(
ctx.orderId(),
ctx.items()
);
return ctx.withInventoryReservationId(reservationId);
}
private OrderContext chargePayment(OrderContext ctx) {
log.info("Cobrando pagamento para pedido: {}, valor: {}",
ctx.orderId(), ctx.totalAmount());
PaymentCharge charge = paymentService.charge(
ctx.paymentMethodId(),
ctx.totalAmount(),
ctx.orderId() // Usado como chave de idempotência
);
return ctx.withPaymentChargeId(charge.getId());
}
private OrderContext scheduleShipping(OrderContext ctx) {
log.info("Agendando envio para pedido: {}", 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("Notificando cliente: {} para pedido: {}",
ctx.customerId(), ctx.orderId());
notificationService.sendOrderConfirmation(
ctx.customerId(),
ctx.orderId(),
ctx.trackingNumber()
);
return ctx;
}
// ═══════════════════════════════════════════════════════════════
// MÉTODOS COMPENSATE
// ═══════════════════════════════════════════════════════════════
private void releaseInventory(OrderContext ctx) {
log.info("Compensando: liberando reserva de inventário: {}",
ctx.inventoryReservationId());
if (ctx.inventoryReservationId() != null) {
inventoryService.release(ctx.inventoryReservationId());
}
}
private void refundPayment(OrderContext ctx) {
log.info("Compensando: reembolsando cobrança: {}", ctx.paymentChargeId());
if (ctx.paymentChargeId() != null) {
paymentService.refund(ctx.paymentChargeId());
}
}
}
Configuração
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)
);
}
}
Como Fica uma Falha
Se o pagamento falhar após a reserva de inventário, GET /api/sagas/{id} mostrará:
{
"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": "Cartão recusado: saldo insuficiente"
}
]
}
A reserva de inventário foi liberada automaticamente. O cliente não foi cobrado. Sem limpeza manual necessária.