Pular para o conteúdo principal

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 etapaO que deve ser desfeito
charge-payment falhaLiberar a reserva de inventário
schedule-shipping falhaReembolsar o pagamento + liberar inventário
notify-customer falhaTentar 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.


Relacionados