Order Placement Flow

Step by Step

  1. App sends POST /api/orders with a JWT and an Idempotency-Key. The gateway validates the token and routes to order-service.
  2. order-service checks the idempotency key, reads current prices from product-service (RestClient with timeout and circuit breaker), and in one local transaction saves the order as PENDING, the saga state and a ReserveStock command in the outbox. It returns 202 Accepted.
  3. The outbox relay publishes the command. inventory-service reserves stock and replies StockReserved (via its own outbox).
  4. The orchestrator sends ChargePayment. payment-service captures the UPI/card payment with Razorpay (idempotent on the order ID) and replies PaymentCompleted.
  5. The orchestrator marks the order CONFIRMED and publishes OrderConfirmed. notification-service sends the SMS and email.
  6. The app polls GET /api/orders/{id} (or receives a push notification) and shows "Order confirmed".

Example

curl -X POST https://api.shopease.in/api/orders \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
  -H "Idempotency-Key: 5b1e7c2a-91f0-4d3e-a7b2-0c9d8e7f6a51" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"productId":1,"quantity":2}],"addressId":12,"paymentMethod":"UPI"}'

curl -s https://api.shopease.in/api/orders/1001 -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."

Example output

HTTP/1.1 202 Accepted
Location: /api/orders/1001
X-Correlation-Id: 5b1e7c2a-91f0-4d3e-a7b2-0c9d8e7f6a51
{"orderId":1001,"status":"PENDING","total":2998.00}

HTTP/1.1 200 OK
{"orderId":1001,"status":"CONFIRMED","total":2998.00,"items":[{"productId":1,"name":"boAt Rockerz 450","quantity":2,"price":1499.00}],"paymentId":"pay_NqX81kLm2"}

Example: The Whole Flow in One Program

Example

A compact simulation of the capstone flow: idempotency at the entry, an orchestrated saga with compensation, outbox events and a notification consumer. The saga runs synchronously here so the output is easy to follow; in production it is asynchronous and the API answers 202 PENDING first.

import java.util.*;

public class Main {
    static int stock = 10, wallet = 5000, nextOrder = 1001;
    static final Map<String, String> idempotency = new HashMap<>();
    static final Map<Integer, String> orders = new TreeMap<>();
    static final List<String> outbox = new ArrayList<>();

    public static void main(String[] args) {
        System.out.println(placeOrder("key-A", 2, 1499));
        System.out.println(placeOrder("key-A", 2, 1499));    // client retry after a timeout
        System.out.println(placeOrder("key-B", 20, 1499));   // more than the stock
        wallet = 1000;
        System.out.println(placeOrder("key-C", 1, 3999));    // payment declined
        System.out.println("orders=" + orders + " stock=" + stock + " wallet=Rs." + wallet);
        relayOutbox();
    }

    static String placeOrder(String key, int qty, int price) {
        if (idempotency.containsKey(key)) return "  -> 200 " + idempotency.get(key) + " (replayed, nothing charged)";
        int id = nextOrder++;
        orders.put(id, "PENDING");
        log("order-service", "order " + id + " PENDING, Rs." + qty * price + " (saga started)");
        String status = saga(qty, qty * price);
        orders.put(id, status);
        outbox.add((status.equals("CONFIRMED") ? "OrderConfirmed" : "OrderCancelled") + "#" + id);
        String body = "{orderId:" + id + ", status:" + status + "}";
        idempotency.put(key, body);
        return "  -> 201 " + body;
    }

    static String saga(int qty, int amount) {
        if (qty > stock) { log("inventory-service", "StockRejected: only " + stock + " left"); return "CANCELLED"; }
        stock -= qty;
        log("inventory-service", "StockReserved " + qty);
        if (amount > wallet) {
            log("payment-service", "PaymentDeclined: balance Rs." + wallet);
            stock += qty;
            log("inventory-service", "StockReleased " + qty + " (compensation)");
            return "CANCELLED";
        }
        wallet -= amount;
        log("payment-service", "PaymentCompleted Rs." + amount);
        return "CONFIRMED";
    }

    static void relayOutbox() {
        for (String event : outbox) {
            log("outbox-relay", "order-events <- " + event);
            String[] p = event.split("#");
            log("notification-svc", "SMS: Your ShopEase order " + p[1]
                    + (p[0].equals("OrderConfirmed") ? " is confirmed." : " was cancelled. Any amount paid will be refunded."));
        }
    }

    static void log(String service, String message) {
        System.out.printf("  [%-17s] %s%n", service, message);
    }
}

Output

  [order-service    ] order 1001 PENDING, Rs.2998 (saga started)
  [inventory-service] StockReserved 2
  [payment-service  ] PaymentCompleted Rs.2998
  -> 201 {orderId:1001, status:CONFIRMED}
  -> 200 {orderId:1001, status:CONFIRMED} (replayed, nothing charged)
  [order-service    ] order 1002 PENDING, Rs.29980 (saga started)
  [inventory-service] StockRejected: only 8 left
  -> 201 {orderId:1002, status:CANCELLED}
  [order-service    ] order 1003 PENDING, Rs.3999 (saga started)
  [inventory-service] StockReserved 1
  [payment-service  ] PaymentDeclined: balance Rs.1000
  [inventory-service] StockReleased 1 (compensation)
  -> 201 {orderId:1003, status:CANCELLED}
orders={1001=CONFIRMED, 1002=CANCELLED, 1003=CANCELLED} stock=8 wallet=Rs.1000
  [outbox-relay     ] order-events <- OrderConfirmed#1001
  [notification-svc ] SMS: Your ShopEase order 1001 is confirmed.
  [outbox-relay     ] order-events <- OrderCancelled#1002
  [notification-svc ] SMS: Your ShopEase order 1002 was cancelled. Any amount paid will be refunded.
  [outbox-relay     ] order-events <- OrderCancelled#1003
  [notification-svc ] SMS: Your ShopEase order 1003 was cancelled. Any amount paid will be refunded.

The Order Endpoint

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
    private final PlaceOrderService placeOrder;

    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:write')")
    public ResponseEntity<OrderResponse> place(@RequestHeader("Idempotency-Key") String key,
                                               @Valid @RequestBody PlaceOrderRequest req,
                                               @AuthenticationPrincipal Jwt jwt) {
        OrderResponse order = placeOrder.place(jwt.getSubject(), key, req);    // idempotent
        return ResponseEntity.accepted()
                .location(URI.create("/api/orders/" + order.orderId()))
                .body(order);                                                  // status PENDING
    }
}

@Service
@RequiredArgsConstructor
public class PlaceOrderService {
    private final OrderRepository orders;
    private final ProductClient products;             // @CircuitBreaker + timeout 800 ms
    private final OrderSagaOrchestrator saga;

    @Transactional
    public OrderResponse place(String userId, String key, PlaceOrderRequest req) {
        return orders.findByIdempotencyKey(key).map(OrderResponse::from).orElseGet(() -> {
            List<PricedItem> items = products.price(req.items());               // current prices
            Order order = orders.save(Order.pending(userId, key, items, req.addressId()));
            saga.start(order);                                                  // saga row + outbox command
            return OrderResponse.from(order);
        });
    }
}