Design the backend architecture for an e-commerce order flow across microservices — walk through the services and how they communicate
One order touches five services and two communication styles — walk through the flow and the sagas that keep it correct without a distributed transaction.
LEARN BY DOINGPut your intuition to work3 steps
Design the backend architecture for an e-commerce order flow across microservices — walk through the services and how they communicate
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Framing the problem
"Design the backend for placing an order across microservices" is one of the most common senior backend system-design prompts precisely because a correct answer requires almost everything else in this module at once: service boundaries drawn around business capabilities, a mix of synchronous and asynchronous communication chosen deliberately rather than by habit, sagas and compensating actions instead of a distributed transaction, idempotency everywhere retries can happen, and observability that can reconstruct one logical order across five independent services. There is no single correct architecture, but there is a correct set of concerns a strong answer has to address, and this lesson walks through one coherent design that hits all of them.
#The services and what each one owns
- API Gateway / Backend-for-Frontend — the single entry point for clients: authentication, rate limiting, request routing, and (often) aggregating a few downstream calls into one response shape for the frontend. It owns no business data.
- Order Service — owns the order's lifecycle as an explicit state machine (CREATED → PENDING_PAYMENT → PAID → FULFILLING → SHIPPED → DELIVERED, with CANCELLED reachable from most of those states) and the order's own database. It is the source of truth for "what state is this order in," even though it does not itself perform payment or shipping.
- Inventory Service — owns stock levels and reservations in its own database. It is the only service allowed to increment or decrement a stock count.
- Payment Service — owns payment records and idempotent interaction with an external payment processor (Stripe, Adyen, a card network, whatever). It never lets the Order Service or anyone else touch a card number directly.
- Shipping / Fulfillment Service — owns shipment records and integration with carrier APIs, and tracks a shipment through its own state machine (CREATED → DISPATCHED → IN_TRANSIT → DELIVERED).
- Notification Service — owns no business state that anyone else depends on; it exists purely to translate domain events into customer-facing communication (email, SMS, push), and its own downtime should never be able to block an order.
- Event bus — Kafka, RabbitMQ, or similar, the backbone that lets Order, Inventory, Payment, and Shipping propagate state changes to everyone who needs to react, without knowing who those consumers are.
#Walking through the flow
- Client places the order. The client calls the API Gateway, which calls the Order Service synchronously (REST or gRPC). Order Service validates the request, persists a new order in CREATED / PENDING_PAYMENT state, and returns the order id immediately — it does not block the client on inventory, payment, or shipping, all of which happen afterward. This is the single most important design decision in the whole flow: the user-facing request only has to wait for the part that determines what to show them next, not for the entire fulfillment pipeline.
- Order Service publishes OrderCreated, using a transactional outbox — the event row is written in the same database transaction as the order itself, and a relay process publishes it to the broker, so the event is guaranteed to go out if and only if the order was actually persisted.
- Inventory Service consumes OrderCreated and attempts to reserve stock for each line item. It publishes InventoryReserved on success or InventoryReservationFailed on failure (e.g., out of stock).
- Order Service consumes the inventory result. On failure, it transitions the order to CANCELLED and publishes OrderCancelled — no payment was ever attempted. On success, it calls the Payment Service, and here the design deliberately switches back to synchronous: many teams charge the card via a direct request/response call (carrying an idempotency key derived from the order id) because the checkout experience commonly still wants to know, within the same interaction, whether the charge succeeded, was declined, or needs a secondary action like 3-D Secure. (A fully async alternative — return "processing" immediately and notify the client later via webhook or polling — is also valid and common with providers whose payment flows are inherently asynchronous; call this out explicitly if asked, since either is defensible.)
- Payment Service charges the customer through the external processor, using the idempotency key so a network retry from Order Service cannot double-charge the same order, records the transaction, and publishes PaymentSucceeded or PaymentFailed.
- Order Service reacts to the payment result. On success, it moves to PAID and publishes OrderPaid. On failure, it moves to CANCELLED and — critically — publishes an event that tells Inventory Service to release the earlier reservation. This release is a compensating action: there is no distributed transaction spanning the Order and Inventory databases, so correctness comes from explicitly undoing the forward step, not from an atomic commit across services.
- Shipping Service consumes OrderPaid, creates a shipment record, and integrates with a carrier API, publishing ShipmentDispatched and later ShipmentDelivered as the physical process progresses. Order Service subscribes to these events purely to keep its own state machine (SHIPPED, DELIVERED) up to date — it does not talk to the carrier directly.
- Notification Service subscribes broadly — OrderCreated, PaymentFailed, OrderCancelled, OrderPaid, ShipmentDispatched — and fans each one out to the appropriate customer message, entirely decoupled from the core flow, so a slow or down email provider can never hold up an order.
#Choreography vs. orchestration
The flow above is choreography: each service reacts to events published by others, with no central coordinator. It scales well organizationally (each team owns its service's reactions) and has no single new component to operate, but it can become hard to trace — "what actually happens when an order is placed" is scattered across five codebases' event handlers, and complex conditional branching (partial fulfillment, backorders, split shipments across warehouses) gets harder to reason about the more steps you add. The alternative is orchestration: an explicit Order Saga Orchestrator issues each step as a command (ReserveInventory, ChargePayment, CreateShipment) and explicitly issues the compensating command if a step fails, which trades a new central dependency for much better visibility, easier testing of the whole workflow, and a natural place to add complex branching logic later. Interviewers are generally happy with either, provided you can articulate why you chose it and what you gave up.
#Sagas and compensations, explicit
| Forward step | Compensating action if a later step fails |
|---|---|
| Reserve inventory | Release the reservation |
| Charge payment | Refund the transaction |
| Create and dispatch shipment | Cancel the shipment, or initiate a return if it already left the warehouse |
#Cross-cutting concerns a strong answer names explicitly
- Idempotency everywhere. Brokers deliver at-least-once, not exactly-once, and network retries duplicate calls; every consumer must dedupe by an event or order id, and every side-effecting external call (the payment charge above all) needs an idempotency key.
- Data ownership. Each service owns its database exclusively; no service reaches into another's tables. Cross-service reads happen through APIs or through locally cached projections built from events, never through a shared schema.
- Why not a distributed transaction (2PC)? Two-phase commit across four independently owned databases (and an external payment processor that will never participate in your 2PC coordinator) is both technically impractical and an availability hazard — it blocks all participants for the duration of the commit and turns any one participant's downtime into everyone's downtime. Sagas trade strict atomicity for availability and accept a brief window of visible intermediate state, compensated explicitly if something later fails.
- Observability. A single logical "place this order" operation now spans five services and multiple asynchronous hops; a correlation id (or full distributed trace id) generated at the API Gateway must be propagated through every synchronous call header and every event's metadata so the whole journey can be reconstructed from logs and traces after the fact.
🧠 Check your understanding
0/2 · 0/2 answered1. In the order flow, why does Order Service typically call Payment Service synchronously while it calls Shipping and Notification asynchronously via events?
2. If the Payment Service reports a failed charge after Inventory has already reserved stock for the order, what should happen?