Hard system-design java

How would you design ordering across multiple restaurant outlets?

The 30 second answer

I choose an outlet before accepting an order, because price, availability and kitchen capacity belong to that outlet. I reserve its stock with a guarded database update, not a read followed by a write. Order creation and reservation commit together when they share a database. Payment runs outside that transaction, with retries and compensation. I scale stateless request handling separately from kitchens and watch hot outlets before adding more application instances.

Requirements first. Support outlet discovery, outlet-specific menus and prices, order placement, payment, kitchen acceptance, preparation, pickup or delivery, cancellation and refunds. Ask whether one cart may span outlets. Start with one outlet per order: a cross-outlet cart becomes several child orders with independent stock and preparation, not one magical local transaction. Clarify whether inventory counts saleable portions or ingredients; for ingredient inventory expand menu items into a bill of materials and reserve all required ingredients. Non-functional priorities are no overselling, retry-safe orders, failure isolation between outlets and measurable peak-hour latency. Kitchen throughput is a physical constraint: software replicas cannot cook faster.

Modules or services and ownership. Catalog/Menu owns items and outlet prices; Ordering owns order state, line snapshots and idempotency; Inventory owns per-outlet available stock and reservations; Payment owns provider references and reconciled payment status; Fulfillment owns the kitchen queue; Dispatch owns driver assignment when delivery is included. These can be modules of a Spring Boot application first, not one microservice per table. If Inventory and Ordering share a database and transaction, use that local atomicity. If they have separate databases, use an orchestrated reservation/payment workflow with compensations and timeouts; do not claim a Spring transaction spans services. Async notifications and analytics should not block checkout.

Schema sketch. Outlet(id, openingHours, acceptingOrders); MenuItem(id); OutletMenu(outletId, itemId, price, enabled); Inventory(outletId, skuId, available CHECK available >= 0, primary key outletId+skuId); Order(id, outletId, customerId, status, total, createdAt); OrderLine(orderId, skuId, qty CHECK qty > 0, priceSnapshot); Reservation(orderId, status, expiresAt); Payment(orderId, providerRef, status); RequestResult(customerId, idempotencyKey, requestHash, orderId); Outbox(eventId, aggregateId, payload). Keep unique keys on customer+idempotencyKey and provider payment identity. Store currency explicitly and integer minor units or suitable decimal money types, never binary floating-point arithmetic for totals. Derive prices server-side and snapshot them in lines; do not trust the client's total.

APIs and flow. GET /outlets?location=…; GET /outlets/{id}/menu; POST /orders with outlet, item quantities and Idempotency-Key; GET /orders/{id}; POST /orders/{id}/cancel; POST /outlets/{id}/orders/{id}/accept. Authorize customer/outlet access on every command. Reusing an idempotency key with a different request hash is a conflict. For a matching retry, return the existing order and state rather than creating another order.

  1. Validate outlet opening/accepting status, menu version, positive quantities and cart bounds. Aggregate duplicate SKU lines and determine all stock quantities before touching inventory.
  2. Within one local transaction, claim the request key, create PENDING_PAYMENT order/line snapshots, conditionally decrement each stock row in sorted SKU order, create ACTIVE reservation, and append OrderReserved outbox event. If any decrement changes zero rows, roll back the entire transaction. An idempotency-key collision must resolve to the original request; do not continue stock deductions as though it were a new order.
  3. After commit, authorize/charge through Payment using a stable provider idempotency key derived from the order. The response can be PENDING rather than pretending the distributed workflow finished synchronously.
  4. On confirmed payment, conditionally change ACTIVE reservation to CONSUMED and order to CONFIRMED in one local transaction. The kitchen sees a durable event. Duplicate callbacks must not consume stock twice. The available counter was already decremented at reservation, so consumption does not decrement it again.
  5. On definitive failure/expiry, lock the order/reservation, change ACTIVE to RELEASED only once, add its quantities back and mark cancellation in the same transaction. Payment success racing expiry must use the same state guard: whichever wins determines the next action. If expiry won but a charge later succeeds, reconcile and refund or re-reserve explicitly; never prepare an unreserved order. A timeout is an unknown payment result, not proof of failure.

The concurrency-critical SQL. Assume the inventory table and one row already exist. Here is a complete one-row demonstration in PostgreSQL; application code checks that exactly one row is returned before committing. Production applies the statement to every normalized line inside the same order transaction, in deterministic key order.

BEGIN;
CREATE TEMP TABLE demo_inventory (
  outlet_id bigint NOT NULL,
  sku_id bigint NOT NULL,
  available integer NOT NULL CHECK (available >= 0),
  PRIMARY KEY (outlet_id, sku_id)
) ON COMMIT DROP;
INSERT INTO demo_inventory VALUES (7, 42, 1);
UPDATE demo_inventory
SET available = available - 1
WHERE outlet_id = 7 AND sku_id = 42 AND available >= 1
RETURNING available;  -- one row: 0 (reservation succeeded)
UPDATE demo_inventory
SET available = available - 1
WHERE outlet_id = 7 AND sku_id = 42 AND available >= 1
RETURNING available;  -- no row: sold out
COMMIT;

PostgreSQL Read Committed waits for concurrent writers on that row, then re-evaluates the WHERE condition against the committed updated row. With one available item and two concurrent transactions, at most one such decrement succeeds. This is not equivalent to SELECT available followed by writing a previously calculated absolute value. A CHECK is useful defense in depth; it does not replace reservation identity or retry handling. For multi-row invariants, lock the relevant rows in consistent order, or use Serializable and retry the entire transaction on serialization failure. Never keep inventory locks while calling a payment API.

Useful queries, indexing and hot-outlet diagnosis. Index recent customer orders on (customerId, createdAt, id), and kitchen work on (outletId, status, createdAt, id). Keyset page history rather than returning every order. A kitchen query filters outlet and active status then orders by creation time with a bounded limit; workers must atomically claim work, not independently SELECT the same ticket and both cook it. Start incident diagnosis by separating slow menu reads, lock waits on hot stock, payment delay and kitchen backlog. Cache menu discovery but always revalidate authoritative prices and stock when reserving.

Scaling. Partition or route work by outlet when measurements justify it, and isolate noisy locations. Scale stateless order APIs and asynchronous workers independently; cap their total database connections. Kubernetes HPA can scale from resource, custom or external metrics, but a queue-age/backlog metric needs an installed metrics adapter. CPU alone misses an I/O-bound checkout waiting on locks. More pods can increase contention on the last popular SKU: short transactions, admission control, bounded retries and an outlet acceptance/capacity gate are more useful there. If stock is a single hot key, partitioning by outlet isolates other outlets but does not make that one row arbitrarily parallel.

Validation. Race two orders for the last item; only one may commit. Retry an order after a lost HTTP response; stock must move once. Reject one missing ingredient and verify every other decrement rolled back. Replay payment webhooks, race success with expiry, crash after commit before publication, and replay an outbox event. Check available inventory plus active/consumed quantities against a reconciliation ledger, allowing explicit replenishment and adjustments. Measure reservation conflicts, transaction/lock duration, payment-unknown age, outbox lag, kitchen queue age and customer-visible latency per outlet. An in-memory synchronized block alone is not a stock consistency mechanism across application replicas.

Sources (4)
  1. PostgreSQL 18: Read Committed concurrent UPDATE and WHERE re-evaluation
  2. PostgreSQL 18: Row locks, deadlocks and consistent lock order
  3. Chris Richardson: Transactional outbox, relay retries and idempotent consumers
  4. Kubernetes: Horizontal Pod Autoscaling and custom/external metrics