Warehouse/Product/StockItem/Supplier/Order classes, stock reservation during checkout reusing the seat-lock pattern, and low-stock alerting as an Observer hook.
Published September 23, 2026
class Warehouse { String location; Map<String, StockItem> stockByProductId; }
class Product { String id; String name; int lowStockThreshold; }
class StockItem { Product product; int quantityOnHand; int quantityReserved; } // reserved != on-hand — see below
class Supplier { String name; List<Product> suppliedProducts; }
class Order { List<OrderLine> lines; OrderStatus status; }
class InventoryService {
boolean reserveStock(String productId, int quantity, Warehouse warehouse) {
StockItem item = warehouse.stockByProductId.get(productId);
synchronized (item) { // per-item lock, same principle as per-spot/per-seat locking elsewhere in this course
int available = item.quantityOnHand - item.quantityReserved;
if (available < quantity) return false;
item.quantityReserved += quantity; // reserved, not yet deducted from on-hand
return true;
}
}
void confirmReservation(String productId, int quantity, Warehouse warehouse) {
StockItem item = warehouse.stockByProductId.get(productId);
synchronized (item) {
item.quantityOnHand -= quantity;
item.quantityReserved -= quantity;
}
}
void releaseReservation(String productId, int quantity, Warehouse warehouse) {
synchronized (warehouse.stockByProductId.get(productId)) {
warehouse.stockByProductId.get(productId).quantityReserved -= quantity; // order abandoned/expired — release the hold
}
}
}
Tracking quantityReserved separately from quantityOnHand is the key modeling decision — it's structurally identical to Movie Ticket Booking System's HELD vs BOOKED seat states and Parking Lot's spot-claiming: a reservation temporarily removes stock from what's available to other orders without yet committing to a permanent deduction, so an abandoned checkout can cleanly release the hold via releaseReservation() rather than needing to "add back" a quantity that was never actually removed from quantityOnHand in the first place.
interface LowStockListener { void onLowStock(Product product, int currentQuantity); }
class InventoryService {
private final List<LowStockListener> listeners = new CopyOnWriteArrayList<>();
void confirmReservation(String productId, int quantity, Warehouse warehouse) {
StockItem item = warehouse.stockByProductId.get(productId);
synchronized (item) {
item.quantityOnHand -= quantity;
item.quantityReserved -= quantity;
if (item.quantityOnHand < item.product.lowStockThreshold) {
listeners.forEach(l -> l.onLowStock(item.product, item.quantityOnHand)); // notify, don't decide what happens next
}
}
}
}
InventoryService doesn't know or care what happens on low stock — sending a supplier reorder email, paging an ops team, updating a dashboard — it just fires the event (Observer Pattern) and lets registered listeners decide. Coupling reorder-email logic directly into confirmReservation() would violate Single Responsibility (inventory tracking and notification delivery are genuinely separate concerns) and make adding a second reaction (e.g. also updating a dashboard) require editing InventoryService itself rather than just registering a second listener.
The core design distinguishes quantityOnHand from quantityReserved. Two gaps remain: a reservation from an abandoned checkout must eventually be released, and every stock change should be explainable after the fact. Both are solved by treating reservations and movements as records, not just counters:
record Reservation(String id, String orderId, String productId, String warehouseId,
int quantity, Instant expiresAt) {}
enum MovementType { RECEIVED, RESERVED, RELEASED, SHIPPED, ADJUSTED, RETURNED }
record StockMovement(String productId, String warehouseId, MovementType type, int quantity,
String reference, Instant at) {}
class InventoryService {
private final Map<String, StockItem> stock; // key: warehouseId + ":" + productId
private final Map<String, Reservation> reservations = new ConcurrentHashMap<>();
private final List<StockMovement> ledger; // append-only
private final Clock clock;
Optional<Reservation> reserve(String orderId, String warehouseId, String productId, int qty) {
StockItem item = stock.get(warehouseId + ":" + productId);
synchronized (item) {
if (item.available() < qty) return Optional.empty(); // available = onHand - reserved
item.quantityReserved += qty;
}
Reservation r = new Reservation(UUID.randomUUID().toString(), orderId, productId, warehouseId,
qty, clock.instant().plus(Duration.ofMinutes(15)));
reservations.put(r.id(), r);
ledger.add(new StockMovement(productId, warehouseId, MovementType.RESERVED, qty, orderId, clock.instant()));
return Optional.of(r);
}
/** Called when the order is paid: stock physically leaves. */
boolean commit(String reservationId) {
Reservation r = reservations.remove(reservationId);
if (r == null) return false; // already expired or released
StockItem item = stock.get(r.warehouseId() + ":" + r.productId());
synchronized (item) {
item.quantityReserved -= r.quantity();
item.quantityOnHand -= r.quantity();
}
ledger.add(new StockMovement(r.productId(), r.warehouseId(), MovementType.SHIPPED, r.quantity(), r.orderId(), clock.instant()));
return true;
}
/** Scheduled every minute: return expired holds to available stock. */
void releaseExpired() {
Instant now = clock.instant();
reservations.values().stream().filter(r -> !r.expiresAt().isAfter(now)).toList()
.forEach(r -> release(r.id()));
}
void release(String reservationId) {
Reservation r = reservations.remove(reservationId); // remove() makes release idempotent
if (r == null) return;
StockItem item = stock.get(r.warehouseId() + ":" + r.productId());
synchronized (item) { item.quantityReserved -= r.quantity(); }
ledger.add(new StockMovement(r.productId(), r.warehouseId(), MovementType.RELEASED, r.quantity(), r.orderId(), clock.instant()));
}
}
What this adds:
reservations.remove() guarantees each reservation is committed or released exactly once, even if a late payment confirmation races with the expiry job. commit returning false tells the payment flow to re-reserve or refund.When an order line can be fulfilled from several warehouses, choosing one is a policy, so make it a Strategy:
interface AllocationStrategy {
List<Allocation> allocate(String productId, int qty, Address shipTo, List<StockItem> candidates);
}
class NearestFirstAllocation implements AllocationStrategy {
public List<Allocation> allocate(String productId, int qty, Address shipTo, List<StockItem> candidates) {
List<Allocation> result = new ArrayList<>();
int remaining = qty;
for (StockItem s : candidates.stream()
.sorted(Comparator.comparingDouble(s -> distance(s.warehouse().location(), shipTo))).toList()) {
int take = Math.min(remaining, s.available());
if (take > 0) { result.add(new Allocation(s.warehouse().id(), take)); remaining -= take; }
if (remaining == 0) return result;
}
return List.of(); // can't fully allocate: backorder or reject
}
}
Alternatives: prefer a single warehouse even if it's farther (one shipment instead of two), or prefer warehouses with excess stock. The service reserves each allocation in turn, and releases all of them if any fails, as an all-or-nothing hold.
The low-stock observer triggers replenishment. A simple, standard rule is the reorder point:
reorderPoint = averageDailyDemand × supplierLeadTimeDays + safetyStock
When available stock drops below the reorder point, create a purchase order for a quantity that brings stock back up to a target level. Guard against duplicate purchase orders by checking for an open one first, because the low-stock event may fire many times while stock stays low.
Q: What happens if reserveStock() succeeds but the order is never confirmed or explicitly released (e.g. the application crashes)? A: Same failure mode as any hold-with-expiry pattern in this course — a reservation needs a timeout/expiry mechanism (see Movie Ticket Booking System's scheduleExpiry discussion) so an abandoned reservation doesn't permanently lock stock away; a durable, sweep-based expiry (not just an in-process timer) matters even more here since inventory holds can be longer-lived than a seat-booking flow.
Q: Why lock per-StockItem rather than per-Warehouse? A: The same fine-grained-locking argument as Parking Lot — Implementation: locking the whole warehouse would serialize reservations across every unrelated product, while per-item locking only contends when two orders target the exact same product, letting unrelated products' reservations proceed fully in parallel.
Q: How would this design handle a single order needing multiple products from potentially different warehouses? A: Each product's reservation would need its own reserveStock() call, and the order as a whole should only confirm once every line's reservation succeeds (an all-or-nothing multi-item commit) — the same atomicity concern as Airline Booking's multi-leg itinerary, applied to order lines instead of flight legs.
Q: Does firing the low-stock event synchronously inside the synchronized block risk anything? A: Yes — a slow or blocking listener would hold the StockItem's lock for the listener's entire execution time, blocking other threads trying to reserve/release that same product. A safer design dispatches listener notifications asynchronously (via an executor, see ExecutorService & Thread Pools) after releasing the lock, rather than synchronously inside the critical section.
Q: Why keep an append-only ledger of stock movements instead of only current quantities? A: Quantities alone can't explain themselves. A ledger records every receipt, reservation, shipment and adjustment, with its reference, so discrepancies can be traced, reports can be rebuilt, and the current quantity can always be recomputed and verified.
Q: What happens if payment confirmation arrives after the reservation has expired?
A: commit finds no reservation and returns false. The order flow should then try to reserve again, if stock is still available, or refund and notify the customer. It must never ship stock that was already released to someone else.
Q: How do you avoid creating duplicate purchase orders when stock stays low? A: Make replenishment idempotent per product and warehouse: before creating a purchase order, check for an open one, and include incoming quantities in the "available + on order" figure that's compared with the reorder point.