SwadhyAI Notes

All Low-Level Design notes

LLD case

Inventory / order system

Two checkouts both see stock 1 for the last mug. If each thread reads available, then writes stock--, both succeed and you sold the same unit twice. That is the invariant this design protects.

A holds the unit, payment commits it, and a expiry returns it if payment never finishes. Only one may win. The same race shows up as occupy on movie seats and parking spots.

Safe checkout path

1

Atomic hold: available goes down, reserved goes up, or the call fails.

2

Pay

Confirm moves reserved to sold. available was already reduced at reserve.

3

If payment never finishes, expire moves reserved back to available.

4

Ship

Only from PAID. Order commands reject every illegal arrow.

What actually happens when two checkouts race

SKU (stock-keeping unit, the product identity) and Stock are inventory. Order is an entity with a state machine: CREATED → RESERVED → PAID → SHIPPED, or CREATED → CANCELLED, or RESERVED → EXPIRED. You must not SHIP from CREATED. You must not reserve below zero. See state machines.

is one step. If decrementing available, incrementing reserved, and recording the order are three unlocked field writes, the last mug sells twice. A use-case controller coordinates reserve, pay, and ship; Stock protects counts; Order protects its arrows. Validation of "qty > 0" belongs at the command edge, and the stock object still protects the count. See validation.

Payment confirmation is on order id. A has a from Clock; a worker or the next command expires stale holds. Do not mix warehouse bins, courier APIs, and invoices into OrderService.

How to choose a locking story

Once you see that unlocked writes lose the mug, you still have to say how Stock serialises .

1. In process? synchronized on Stock (one thread at a time inside the method) is the honest in-process answer.

2. Behind a database? (version column, retry) or a on the sku row are both valid. Pick one and say why. See optimistic vs pessimistic and concurrent transitions.

3. What is not a story? Silent stock-- is neither optimistic nor pessimistic locking. It is a bug.

Simple rule of thumb

Never write silent stock--. Pick or synchronized Stock out loud, then let Order commands own the arrows (see the type table next).

From problem to types

The locking story only works if each type owns one job. Stock protects counts. Order protects its state machine. CheckoutService coordinates. Clock owns hold time.

TypeInvariant
Stockavailable + reserved never lie; never negative; atomic.
OrderExpose commands only. setStatus(SHIPPED) from CREATED is a bug.
CheckoutServiceCoordinates reserve, pay, and expire. Courier APIs stay outside.
Clock holds have a . Tests freeze Clock and expire holds.

Illegal arrows are the design

Those jobs show up as legal arrows. The note on CREATED is the whole point: you cannot SHIP from there.

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
Read the diagram definition
stateDiagram-v2
    [*] --> CREATED
    CREATED --> RESERVED: tryReserve stock
    CREATED --> CANCELLED: cancel
    RESERVED --> PAID: confirm payment
    RESERVED --> EXPIRED: TTL
    RESERVED --> CANCELLED: cancel + release
    PAID --> SHIPPED: ship
    EXPIRED --> [*]
    CANCELLED --> [*]
    SHIPPED --> [*]
    note right of CREATED: cannot SHIP

Java API sketch

The sketch is the critical section, not a compiler. Fix the holdUntil line in your head if you type this: Instant, not a missing paren.

java
class Stock {
  private int available;
  private int reserved;

  synchronized boolean tryReserve(int qty) {
    if (qty <= 0) throw new InvalidQty();
    if (available < qty) return false;
    available -= qty;
    reserved += qty;
    return true;
  }

  synchronized void confirm(int qty) {
    if (qty <= 0) throw new InvalidQty();
    if (reserved < qty) throw new OverConfirm();
    reserved -= qty; // sold; available already reduced at reserve
  }

  synchronized void release(int qty) {
    if (qty <= 0) throw new InvalidQty();
    if (reserved < qty) throw new OverRelease();
    reserved -= qty;
    available += qty;
  }
}

class Order {
  private OrderStatus status = OrderStatus.CREATED;
  void reserve() { /* CREATED → RESERVED */ }
  void pay() { /* RESERVED → PAID */ }
  void ship() { /* PAID → SHIPPED only */ }
  void expire() { /* RESERVED → EXPIRED */ }
  void cancel() { /* from CREATED or RESERVED */ }
}

class CheckoutService {
  private final Clock clock;
  Order place(Sku sku, int qty) {
    if (qty <= 0) throw new InvalidQty();
    Order o = new Order();
    if (!stock.tryReserve(qty)) throw new OutOfStock();
    try {
      o.reserve();
      o.holdUntil(clock.instant().plus(Duration.ofMinutes(10)));
      return o;
    } catch (RuntimeException ex) {
      stock.release(qty); // compensate if order creation fails after reserve
      throw ex;
    }
  }
}

Last mug, two checkouts

Here is the race from the hero, slowed down so you can see each step. Stock available is 1. Thread A and B both call (1). Exactly one returns true. The winner's Order is RESERVED with a .

FakeClock (a test clock you can advance) moves past that TTL; expireDue releases reserved back to available. The loser's checkout failed with OutOfStock; it must not have created a SHIPPED order. Confirm payment on the reserved order is : the second confirm does not decrement reserved twice. Confirm and release reject qty that would make reserved negative. If order creation fails after tryReserve, release compensates so stock is not stranded.

Numbers to know

  • Last unit: Stock(1); exactly one tryReserve wins across two threads.
  • Hold length: ten minutes from Clock, then expireDue may release.
  • qty must be greater than 0, or InvalidQty fires at the edge.

Tests that prove the seams

java
@Test
void lastUnitHasOneWinner() throws Exception {
  Stock stock = new Stock(1);
  AtomicInteger wins = new AtomicInteger();
  Runnable r = () -> { if (stock.tryReserve(1)) wins.incrementAndGet(); };
  Thread a = new Thread(r), b = new Thread(r);
  a.start(); b.start(); a.join(); b.join();
  assertEquals(1, wins.get());
}

What still fails, and what a follow-up changes

The types above still fail if you skip a seam ( as one step, an confirm, or a release) or if the interviewer grows the model. Keep these cases next to the happy path.

Invalid cases

  • if (stock > 0) stock--

    Two threads both see 1. Reservation must be one step.

  • setStatus(SHIPPED) from anywhere

    SHIPPED from CREATED is a bug, not a retry.

  • Confirm payment twice creating two decrements

    Idempotent on order id. Second confirm is a no-op.

  • Expire that does not release reserved

    Expire that skips release leaks stock, so available never returns.

  • qty 0 or negative

    Reject qty 0 or negative at the edge; Stock still guards.

Still fails when…

  • A multi-product cart holds the filter after the mug reserve fails.
  • Partial hold strands reserved units; roll back both, like multi-seat booking.
  • Invoices and courier APIs land in OrderService instead of staying outside.

What the follow-up changes

  • Multi-SKU (several products) carts: reserve all or roll back together.
  • Warehouse bins are another collaborator; Stock still protects counts.
  • Partial ship adds new order states, never setStatus from a servlet.

Wrap-up

You have seen the race, the types, and the seams. Use this forty-five minute plan in a round, then follow the related notes.

How to explain this in an interview

  1. 0–8Prove with two threads racing one remaining unit.
  2. 8–20Code Order command arrows so shipping from CREATED throws.
  3. 20–32Show a expire that releases reserved back to available.
  4. 32–40Make payment confirm on the same order id.
  5. 40–45If they ask, roll back a multi-product cart together.

Key takeaway

You are done when the Last mug seams hold: one winner, legal arrows, and a release if payment never completes. A bare stock-- is not a (see How to choose above).