Design an Online Stock Brokerage System
The one bug this whole problem is really about
A stock brokerage is, underneath the charts and watchlists, a ledger that must never let you spend the same dollar twice. Every interesting design decision flows from one question: when a member places a buy order, what happens to their cash before the order actually fills?
The naive answer — "check the balance, place the order, deduct when it fills" — has a fatal race. Between the check and the fill, the same cash is still visibly "available," so a second order can pass the same check. Place two $15,000 orders against a $20,000 balance and both clear the check; now the exchange fills both and you are $10,000 overdrawn. This is the classic double-spend, and it is exactly the kind of correctness hole the canonical textbook stub hand-waves past.
So the real model is reserve → settle, the same two-phase shape banks use for a card authorization (hold) followed by a capture (settle). We will build the brokerage around that shape and make every type concrete enough to compile.
What the canonical stub gets wrong
The widely-copied reference solution writes placeBuyLimitOrder like this:
if (availableFundsForTrading < quantity * limitPrice) {
return INSUFFICIENT_FUNDS;
}
LimitOrder order = new LimitOrder(stockId, quantity, limitPrice, enforcementType);
order.isBuyOrder = true;
order.saveInDB();
boolean success = StockExchange.placeOrder(order); // funds NEVER deducted hereTwo defects hide in those few lines:
- No reservation.
availableFundsForTradingis read but never decremented at submission time, so two concurrent orders both pass the guard. The funds are checked, not held. - The callback never settles. The stub's
callbackStockExchangeupdates the order status and removes it fromactiveOrders, but it never moves cash or creates aStockLot. The portfolio and the cash balance are simply never updated when a fill arrives. The accounting is missing.
We fix both. The contract we will hold ourselves to: the code below compiles end-to-end with no undeclared types and no invented free functions. Every class it references is declared on this page.
The money model: Account and reserved funds
Member extends Account exactly as the original class diagram says, but we make the cash split explicit. Account owns two numbers, not one: settled cash you could withdraw, and reserved cash that is spoken-for by open orders. "Available to trade" is the difference.
All amounts are modelled in integer cents (long) rather than double. Floating-point dollars accumulate rounding error, and a ledger that loses a cent per trade is a ledger that fails its quarterly reconciliation. The worked example below uses dollar figures for readability, but the code holds cents.
Declaring every type the design touches
Here is the foundation — Account, StockPosition with its reservation API, and StockLot. None of these are hand-waved; the order logic that follows calls only methods declared here.
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public enum ReturnStatus {
SUCCESS, INSUFFICIENT_FUNDS, INSUFFICIENT_QUANTITY, NO_STOCK_POSITION, EXCHANGE_REJECTED
}
public enum OrderStatus {
OPEN, PARTIALLY_FILLED, FILLED, CANCELLED;
public boolean isTerminal() { return this == FILLED || this == CANCELLED; }
/** Legal moves: OPEN -> {PARTIALLY_FILLED,FILLED,CANCELLED},
PARTIALLY_FILLED -> {FILLED,CANCELLED}; terminal is forever. */
public boolean canTransitionTo(OrderStatus next) {
if (this.isTerminal()) return false; // FILLED/CANCELLED never move again
if (next == this) return true; // idempotent restatement of same status
if (this == OPEN) return next != OPEN;
/* PARTIALLY_FILLED */ return next == FILLED || next == CANCELLED;
}
}
// ---- Cash ledger: settled vs. reserved ----------------------------------
public abstract class Account {
protected String id;
protected String name;
// All money in integer cents to avoid floating-point drift.
private long settledCents; // cash that is actually present
private long reservedCents; // cash held against open buy orders
protected Account(String name, long openingCents) {
this.id = UUID.randomUUID().toString();
this.name = name;
this.settledCents = openingCents;
}
public long availableCents() { return settledCents - reservedCents; }
/** Hold funds for an open order. Returns false if not enough is free. */
public synchronized boolean reserve(long cents) {
if (cents > availableCents()) return false;
reservedCents += cents;
return true;
}
/** Release a previously reserved hold back to available (no spend). */
public synchronized void release(long cents) {
reservedCents -= Math.min(cents, reservedCents);
}
/** Convert a reserved hold into a real debit when a fill settles. */
public synchronized void spendReserved(long cents) {
reservedCents -= cents;
settledCents -= cents;
}
/** Credit cash from a sell fill. */
public synchronized void credit(long cents) { settledCents += cents; }
}
// ---- One purchase lot: quantity + the price it was bought at -------------
public class StockLot {
private final String lotId;
private final String stockId;
private long quantity;
private final long costBasisCentsPerShare;
public StockLot(String stockId, long quantity, long costBasisCentsPerShare) {
this.lotId = UUID.randomUUID().toString();
this.stockId = stockId;
this.quantity = quantity;
this.costBasisCentsPerShare = costBasisCentsPerShare;
}
public long quantity() { return quantity; }
public long costBasisCentsPerShare() { return costBasisCentsPerShare; }
}
// ---- All lots of one symbol, with a sell-reservation counter -------------
public class StockPosition {
private final String stockId;
private long totalQuantity; // sum across all lots
private long reservedQuantity; // shares spoken-for by open sell orders
private final Map<String, StockLot> lots = new HashMap<>();
public StockPosition(String stockId) { this.stockId = stockId; }
public long availableQuantity() { return totalQuantity - reservedQuantity; }
/** Hold shares for an open sell order. */
public synchronized boolean reserve(long qty) {
if (qty > availableQuantity()) return false;
reservedQuantity += qty;
return true;
}
/** Give shares back (e.g. a sell order is cancelled). */
public synchronized void release(long qty) {
reservedQuantity -= Math.min(qty, reservedQuantity);
}
/** A buy fill adds a new lot with its own cost basis. */
public synchronized void addLot(StockLot lot) {
lots.put(UUID.randomUUID().toString(), lot);
totalQuantity += lot.quantity();
}
}The order-side types — and where idempotency actually lives
The exchange boundary needs three more types, and this is where we make good on the promise that every referenced class is declared here. OrderFill is one execution report; crucially it carries a fillId, because an at-least-once exchange feed will redeliver the same report. Order owns two invariants that callers must not be trusted to enforce: its status transitions (a plain settable field would let a cancel resurrect a filled order) and its fill de-duplication (a replayed fillId must be booked exactly once). Both live inside the object, so no code path can bypass them.
import java.util.HashSet;
import java.util.Set;
// ---- One execution report. The fillId is what makes replay safe. --------
public class OrderFill {
private final String fillId;
private final long quantity;
private final long executionCentsPerShare;
public OrderFill(String fillId, long quantity, long executionCentsPerShare) {
this.fillId = fillId;
this.quantity = quantity;
this.executionCentsPerShare = executionCentsPerShare;
}
public String fillId() { return fillId; }
public long quantity() { return quantity; }
public long executionCentsPerShare() { return executionCentsPerShare; }
}
// ---- Order guards its OWN status machine and its OWN fill dedup ----------
public abstract class Order {
protected final String id;
private final boolean isBuy;
private final String stockId;
private final long quantity;
private OrderStatus status = OrderStatus.OPEN;
private long filledQuantity;
private final Set<String> appliedFillIds = new HashSet<>(); // seen fillIds
protected Order(String id, boolean isBuy, String stockId, long quantity) {
this.id = id; this.isBuy = isBuy; this.stockId = stockId; this.quantity = quantity;
}
public String id() { return id; }
public boolean isBuyOrder() { return isBuy; }
public String stockId() { return stockId; }
public long quantity() { return quantity; }
public long filledQuantity() { return filledQuantity; }
/** Idempotent. Returns false (and books nothing) if this fillId was already applied. */
public synchronized boolean recordFill(OrderFill fill) {
if (!appliedFillIds.add(fill.fillId())) return false; // duplicate delivery -> no-op
filledQuantity += fill.quantity();
return true;
}
/** The field cannot be set directly; an illegal move throws instead of corrupting state. */
public synchronized void setStatus(OrderStatus next) {
if (!status.canTransitionTo(next))
throw new IllegalStateException("illegal order transition " + status + " -> " + next);
status = next;
}
public synchronized OrderStatus status() { return status; }
public void saveInDB() { /* persist new order */ }
public void updateInDB() { /* persist status/fill change */ }
public abstract long limitCentsPerShare();
}
public class LimitOrder extends Order {
private final long limitCentsPerShare;
public LimitOrder(String id, boolean isBuy, String stockId,
long quantity, long limitCentsPerShare) {
super(id, isBuy, stockId, quantity);
this.limitCentsPerShare = limitCentsPerShare;
}
@Override public long limitCentsPerShare() { return limitCentsPerShare; }
}
// ---- Exchange boundary: one instance in this model ----------------------
public interface StockExchange {
boolean placeOrder(Order order);
static StockExchange getInstance() { return Holder.INSTANCE; }
final class Holder { static final StockExchange INSTANCE = order -> true; }
}Now every type the Member logic touches — Order, LimitOrder, OrderFill, StockExchange — is concrete, and the two correctness guards are not advice in a comment: they are code the object enforces on every caller.
Placing a buy order: reserve at submission
The fix to defect #1 is one extra line of intent: before we touch the exchange, we reserve the worst-case cost. For a limit buy, the worst case is the full quantity × limitPrice — the exchange may fill at the limit but never above it. Once reserved, that cash disappears from availableCents(), so a concurrent order sees the smaller balance and cannot double-spend.
public class Member extends Account {
private final Map<String, StockPosition> positions = new HashMap<>();
private final Map<String, Order> activeOrders = new HashMap<>();
public Member(String name, long openingCents) { super(name, openingCents); }
public ReturnStatus placeBuyLimitOrder(String stockId, long quantity,
long limitCentsPerShare) {
long needed = quantity * limitCentsPerShare; // worst-case cost
if (!reserve(needed)) { // ATOMIC hold
return ReturnStatus.INSUFFICIENT_FUNDS;
}
// Correct, compiling code: real UUID id, no free function.
LimitOrder order = new LimitOrder(
UUID.randomUUID().toString(), true, stockId, quantity, limitCentsPerShare);
order.saveInDB();
if (!StockExchange.getInstance().placeOrder(order)) {
release(needed); // hand the hold back
order.setStatus(OrderStatus.CANCELLED);
return ReturnStatus.EXCHANGE_REJECTED;
}
activeOrders.put(order.id(), order);
return ReturnStatus.SUCCESS;
}
public ReturnStatus placeSellLimitOrder(String stockId, long quantity,
long limitCentsPerShare) {
StockPosition pos = positions.get(stockId);
if (pos == null) return ReturnStatus.NO_STOCK_POSITION;
if (!pos.reserve(quantity)) return ReturnStatus.INSUFFICIENT_QUANTITY;
LimitOrder order = new LimitOrder(
UUID.randomUUID().toString(), false, stockId, quantity, limitCentsPerShare);
order.saveInDB();
if (!StockExchange.getInstance().placeOrder(order)) {
pos.release(quantity);
order.setStatus(OrderStatus.CANCELLED);
return ReturnStatus.EXCHANGE_REJECTED;
}
activeOrders.put(order.id(), order);
return ReturnStatus.SUCCESS;
}Note the symmetry: a buy reserves cash, a sell reserves shares. Both use the same reserve-then-place-then-release-on-rejection shape.
The settlement path — shown in full
This is the half the canonical stub omits. When the exchange reports a fill, onExchangeUpdate records the fill on the order, then calls settle, which converts reserved money into spent money and creates the StockLot. Only when the order reaches a terminal status (FILLED or CANCELLED) do we release whatever was reserved for the shares that never filled. Releasing earlier is the double-spend bug; releasing at the right moment is the whole point.
// Called by StockExchange whenever a fill or terminal status arrives.
public synchronized void onExchangeUpdate(String orderId, OrderFill fill,
OrderStatus newStatus) {
Order order = activeOrders.get(orderId);
if (order == null) return; // unknown / already-settled order
if (fill != null && order.recordFill(fill)) { // dedup: a replayed fillId returns false
settle(order, fill); // money moves ONLY on the first application
}
order.setStatus(newStatus);
order.updateInDB();
if (newStatus == OrderStatus.FILLED || newStatus == OrderStatus.CANCELLED) {
// Terminal: nothing more will fill, so free the leftover hold.
long unfilled = order.quantity() - order.filledQuantity();
if (order.isBuyOrder()) {
// unfilled cash was reserved at the LIMIT price
release(unfilled * order.limitCentsPerShare());
} else {
positions.get(order.stockId()).release(unfilled);
}
activeOrders.remove(orderId);
}
}
/** Turn one fill into a real cash movement and a portfolio change. */
private void settle(Order order, OrderFill fill) {
long shares = fill.quantity();
long execPrice = fill.executionCentsPerShare();
long spent = shares * execPrice;
if (order.isBuyOrder()) {
// We had reserved shares*limit; we actually spend shares*exec.
spendReserved(spent); // debit reserved cash
long priceImprovement =
shares * (order.limitCentsPerShare() - execPrice);
if (priceImprovement > 0) release(priceImprovement); // refund the gap
positions.computeIfAbsent(order.stockId(), StockPosition::new)
.addLot(new StockLot(order.stockId(), shares, execPrice));
} else {
// Sell: shares were already reserved in the position.
positions.get(order.stockId()).release(shares); // give them up
credit(spent); // cash comes in
}
}
}Walk the buy path once: at submit we reserved shares × limit. At settle we spendReserved(shares × exec) and immediately release the per-share gap shares × (limit − exec) — the price improvement the exchange gave us — back to available. The two together drain exactly the part of the hold that this fill consumed; everything still reserved belongs to shares that have not filled yet. That invariant is what makes the final release(unfilled × limit) correct rather than a leak.
The worked example, with the arithmetic kept honest
You place a buy limit order for 100 shares at a $150 limit. At submission the system reserves the worst case: 100 × $150 = $15,000. Your availableCents() drops by exactly that, which is what stops a second order from spending the same money.
The exchange then partially fills 90 shares at an average execution price of $149.98 and the order goes terminal (say it expires for the remaining 10). Settlement does three things:
- Spends
90 × $149.98 = $13,498.20out of the reserved bucket. - Refunds the price improvement: you reserved $150/share but paid $149.98, so
90 × $0.02 = $1.80is released back to available. - The unfilled 10 shares were holding
10 × $150 = $1,500of your cash. On the terminal status that $1,500 is released back to available.
The three numbers reconcile exactly against the original $15,000 hold: $13,498.20 spent + $1.80 refunded + $1,500 released = $15,000.00. The $1,500 figure is the unfilled-shares hold (10 × $150), not the result of $15,000 − $13,498.20 — that subtraction is $1,501.80, and the extra $1.80 is precisely the price-improvement refund, which is released separately. Keeping those two releases distinct is what makes the ledger balance to the cent.
And the portfolio? A new StockLot of 90 shares at a $149.98 cost basis is added to your StockPosition for that symbol. If you already held an earlier lot, the position now carries both — which is exactly why the requirements insist on per-lot tracking: your blended cost basis and your tax statement both depend on knowing what each lot cost.
Why this is the answer an interviewer is listening for
The class list — Account, Member, StockExchange, Order, StockLot, StockPosition, Watchlist, Notification — is the easy part, and every candidate produces it. What separates a strong answer is naming the reserve/settle invariant and being able to say precisely when reserved funds become spent funds versus released funds. That single distinction is where real brokerages live or die, and it is the one thing the textbook stub skips.
If you can defend three claims — (1) funds are reserved atomically at submission so concurrent orders cannot double-spend, (2) a fill spends reserved cash and refunds price improvement, and (3) the unfilled remainder is released only at terminal status — you have demonstrated that you understand the system as a ledger, not just as a bag of classes.
The duplicate-fill race, and why synchronized is not what closes it
Here is the trap that separates a candidate who says "make it idempotent" from one who can prove it. An at-least-once exchange feed redelivers the same execution report; the dangerous case is a duplicate arriving on an order that is still open (a late duplicate after the order goes terminal is already harmless — the order is gone from activeOrders and hits the order == null guard). Trace the same fill f1 (90 shares @ $149.98) delivered twice on our open 100 @ $150 buy, opening cash $20,000:
| Delivery | recordFill(f1) | settle runs? | reserved cents | settled cents |
|---|---|---|---|---|
after reserve | — | — | 1,500,000 | 2,000,000 |
| #1 (fillId f1 new) | appliedFillIds.add("f1") → true | yes | 150,000 | 650,180 |
| #2 (fillId f1 replay) | add("f1") → false | no | 150,000 | 650,180 |
The money is identical after delivery #2 as after #1 — the replay is a no-op. Remove the appliedFillIds guard and delivery #2 calls settle again: spendReserved drives reserved to −1,199,820 and settled to −699,640 — a phantom $13,498.20 spent twice against cash that was never there. That is the double-spend, and it is worth seeing exactly why synchronized does not save you here. onExchangeUpdate is synchronized on the Member, so the two deliveries never interleave — they run strictly one after the other. Serialization only guarantees the second application happens cleanly instead of racing; it still happens. The fix is not mutual exclusion, it is idempotency: recordFill remembers the fillId and refuses the repeat. Locking orders concurrent writes; idempotency is what makes at-least-once delivery correct, and the two are answers to different questions.
The second guard is in the same spirit. OrderStatus.canTransitionTo makes status a state machine, not a settable field: FILLED → OPEN throws inside the object rather than trusting callers, so a cancel that arrives after a terminal fill cannot resurrect the order — it is rejected at the boundary, the same "the object owns its invariant" discipline the reserve/settle cash split relies on.
The honest limit: this ledger lives in one process's memory
Be explicit about the boundary of what is written above, because an interviewer will push on it. settledCents and reservedCents are fields on an in-process object, and every mutation is guarded by synchronized on the Member. That is genuinely enough for the concurrency that matters within one JVM: it serializes one member's own order flow, and a single human placing orders is not a high-contention writer, so the lock is essentially never contended and never a throughput ceiling. The per-member lock also means two different members never block each other — the design scales fine across members on one node.
Two things it does not survive, and you should say so before being asked. First, it does not span processes: run two app servers (the normal topology) and each holds its own Member object with its own reservedCents, so the atomic reserve that stops a double-spend on node A is invisible to node B. The authoritative reserve/settle must therefore execute against the one thing both nodes share — the cash row — as a database statement, e.g. a conditional UPDATE account SET reserved = reserved + :n WHERE settled - reserved >= :n (0 rows affected ⇒ insufficient funds), which the engine serializes on the row's write lock exactly as the in-memory synchronized block does on the object. Second, in-memory holds evaporate on a crash: the reserved bucket is not durable, so a restart would forget every open order's hold and re-open the double-spend. Production keeps each hold as a ledger row and derives available = settled − Σ(open holds) on recovery. The class model on this page is the correct mental model of the invariant; the durable system enforces the identical invariant one level down, at the row instead of the object.
Source
Adapted and corrected from the "Design an Online Stock Brokerage System" object-oriented design problem in the Grokking the Object Oriented Design Interview / educative.io low-level-design problem set, whose original reference implementation supplies the class diagram and requirements. The cash-reservation (reserve/spendReserved/release) and full settlement (onExchangeUpdate → settle) logic shown here is a corrected, compile-checked elaboration of that source's order-placement stub.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design an Online Stock Brokerage System? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design an Online Stock Brokerage System** (OO & Low-Level Design) and want to truly understand it. Explain Design an Online Stock Brokerage System from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design an Online Stock Brokerage System** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design an Online Stock Brokerage System** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design an Online Stock Brokerage System** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.