Online Stock Brokerage System
Accounts, portfolios, market/limit/stop orders, matching, transactions. Order state plus price-time matching strategy.
Context#
An online brokerage lets retail customers place buy/sell orders against an order book, holds their cash and securities in a portfolio, and settles trades. The full surface — accounts, KYC, funding, market-data feeds, order routing, matching, clearing, settlement, regulatory reporting, tax-lot accounting, margin, options, derivatives — is enormous. The LLD round can address maybe a quarter of it; the rest is HLD territory or its own sub-system.
The HLD overlap is acute. Market-data fan-out to millions of subscribers is a streaming HLD problem (publish-subscribe, multicast, conflated feeds). Order routing across exchanges is a smart-order-router HLD problem. Settlement and clearing is multi-day, multi-party, T+1, with a central counterparty — entirely outside an LLD round. The interview discipline is to name those seams (“MarketDataFeed is a publisher interface; OrderRouter is a strategy”) and design the in-process shape: order book, matching, order lifecycle, portfolio mutation on trade.
The interviewer’s hidden objectives, in order:
- Can you declare scope first and recite what you cut? Margin and short-selling are the canonical cuts.
- Can you model four order types (Market, Limit, Stop, StopLimit) without an
if-chain on atypeenum? - Can you draw the Order state machine (Open
->PartiallyFilled->Filled / Cancelled / Expired) and defend illegal transitions? - Can you describe price-time priority matching as a Strategy so it can be swapped for pro-rata or auction matching?
- Can you handle partial fills, concurrent inserts into the book, and cancel-during-match races?
- Can you name where Observer fits (watchlists, price alerts, position-change notifications) versus where it does not (matching itself stays synchronous)?
Requirements (functional and non-functional)#
Scope is the single most points-bearing decision. The cut below fits a 45-minute round.
Functional — in scope.
- An
Accountowns onePortfolio. The portfolio tracks cash balance plus a map ofSymboltoPosition(quantity + average cost). - A
Stock(instrument) has a symbol, last-traded price, and is tradeable on one venue (one order book per symbol in this design). - A customer places orders:
MarketOrder,LimitOrder,StopOrder,StopLimitOrder. Each order has a side (Buy / Sell), quantity, and order-type-specific fields. - An
OrderBookper symbol maintains two priority queues (bids descending, asks ascending) and runs a matching strategy to produceTrades. - Trades create
Transactions that mutate both buyer and seller portfolios atomically (cash out, position in; cash in, position out). - Orders walk a lifecycle:
Open -> PartiallyFilled -> FilledorCancelledorExpired. - A customer has a watchlist of symbols and may register price alerts that fire when a threshold is crossed (Observer on price ticks).
Functional — out of scope (called out explicitly).
- Margin and short-selling. Cash account only; no borrowing, no shorts. A
MarginPolicyseam is named. - Options, futures, derivatives. Equities only.
- Multi-venue routing. One book per symbol. A real broker has an
OrderRouterchoosing among NASDAQ / NYSE / dark pools; that’s a Strategy seam. - Clearing and settlement. Trades settle instantly in this model. T+1, central counterparty, and corporate actions are out.
- Tax-lot accounting (FIFO / LIFO / specific-lot). Position tracks average cost only.
- Regulatory reporting (Reg NMS, MiFID, SEBI circulars). Named, not designed.
- KYC / funding / withdrawals. Cash is just a number for the round.
- Market-data feed fan-out. A
PriceTickis published in-process; the network shape is HLD. - Algorithmic / iceberg / TWAP / VWAP orders. Four order types only.
Reciting these cuts on the whiteboard first is the high-altitude move.
Non-functional.
- The model should support
~10^4symbols and~10^6orders per day in principle, but the OOD round optimises only the in-process shape — persistence is a seam. - Matching is synchronous per order book: an
OrderBookhas a single writer (one matching thread); concurrent submissions queue. - Order state transitions are observable (audit, notifications, downstream risk).
- Two customers buying the same last share must not both succeed.
Use case diagram#
┌────────────────┐ │ Trader │ └────────┬───────┘ │ ┌────────────────┼────────────────┬────────────────┐ ▼ ▼ ▼ ▼ [view portfolio] [place order] [cancel order] [manage watchlist] │ │ │ │ ▼ ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────┐ │ Online Brokerage System │ └─────┬───────────────┬─────────────────┬─────────────────┘ ▲ ▲ ▲ │ │ │ ┌───────────┐ ┌──────────────┐ ┌────────────┐ │ MarketData│ │ Matching │ │ Settlement │ ├───────────┤ ├──────────────┤ ├────────────┤ │ price tick│ │ price-time │ │ instant │ │ publisher │ │ priority │ │ (cut: T+1) │ └───────────┘ └──────────────┘ └────────────┘Three actors: the trader, the market-data publisher (external feed), and the matching engine (internal). Settlement is shown as instant — the real T+1 flow is cut.
Class diagram#
┌────────────────────────┐ ┌────────────────────────┐ │ Account │ │ Stock │ ├────────────────────────┤ ├────────────────────────┤ │ id, name, email │ │ symbol, name │ │ portfolio : Portfolio │ │ lastPrice : Money │ │ watchlist : Set<Symbol>│ │ tick(price) │ └──────────┬─────────────┘ └────────────────────────┘ │ 1 ▼ ┌────────────────────────┐ ┌────────────────────────┐ │ Portfolio │ │ Position │ ├────────────────────────┤ ├────────────────────────┤ │ cash : Money │ 1 * │ symbol : Symbol │ │ positions : ├────────►│ quantity : int │ │ Map<Symbol,Position> │ │ avgCost : Money │ │ apply(Trade, side) │ │ apply(qty, price, side)│ └────────────────────────┘ └────────────────────────┘
┌────────────────────────────┐ │ Order │◁──────┬───── MarketOrder ├────────────────────────────┤ ├───── LimitOrder (limitPrice) │ id, account, symbol, side │ ├───── StopOrder (stopPrice) │ qty, filledQty │ └───── StopLimitOrder (stop + limit) │ status : OrderStatus │ │ ts : Instant │ │ fill(qty, price) │ │ cancel() │ └─────────────┬──────────────┘ │ * ▼ ┌────────────────────────┐ ┌────────────────────────┐ │ OrderBook │ │ MatchingStrategy │◁── PriceTime, ├────────────────────────┤ ├────────────────────────┤ ProRata (cut) │ symbol │ uses │ match(bids, asks, in) │ │ bids : PQ (desc) ├────────►│ : List<Trade> │ │ asks : PQ (asc) │ └────────────────────────┘ │ stops : List<StopOrder>│ │ submit(Order) │ ┌────────────────────────┐ │ cancel(orderId) │ │ Trade │ └────────────────────────┘ ├────────────────────────┤ │ id, symbol, qty, price │ │ buyOrderId, sellOrderId│ │ ts │ └────────────────────────┘Four patterns are doing the load-bearing work:
- State pattern on
Order.status.Open -> PartiallyFilled -> Filled | Cancelled | Expired. Fills advance the state; cancel is only legal while the order is resting. - Strategy pattern on
MatchingStrategy. Price-time is the default; pro-rata, auction, and call-market matching are alternative strategies. TheOrderBookdoes not know the rules — it knows it has bids, asks, and a strategy. - Strategy pattern on
Ordersubtypes for crossing logic — aMarketOrdercrosses any price, aLimitOrdercrosses only at-or-better, aStopOrderbecomes active only after a trigger. TheOrder.canMatch(counterparty)method dispatches polymorphically. - Observer pattern on price ticks. Watchlists, price alerts, and stop-order triggers all subscribe to
Stock.tick(price). Matching itself is not Observer-driven — it is synchronous on order submission.
What is deliberately not in the diagram:
- No
BrokerManagergod class. TheOrderBookowns matching; theAccountowns its portfolio; nothing else needs to know. - No margin / loan / borrow ledger. Cut.
- No
Exchangeaggregate. One book per symbol; the venue is implicit.
Sequence diagram (key flows)#
Limit order that crosses immediately and fills partially:
Trader BrokerSvc OrderBook Strategy Order(buy) Order(sell) Portfolio(B) Portfolio(S) │ place(buy 100 @ 50) │ │ │──────►│ │ │ │ │ │ │ │ │ submit(o) │ │ │ │ │ │ │ │───────────►│ │ │ │ │ │ │ │ │ match(bids, asks, o) │ │ │ │ │ │ │───────────►│ │ │ │ │ │ │ │ peek best ask (60 @ 50) │ │ │ │ │ │ cross? yes; trade qty = min(100,60) = 60 │ │ │ │ │ o.fill(60,50) │ │ │ │ │ │ │───────────────────────► │ │ │ │ │ │ │ ask.fill(60,50) │ │ │ │ │ │ │───────────────────────────────────────► │ │ │ │ │ apply Trade to buyer's portfolio │ │ │ │ │───────────────────────────────────────────────────────► │ │ │ │ apply Trade to seller's portfolio │ │ │ │─────────────────────────────────────────────────────────────────────► │ │ │ remove ask (fully filled); rest 40 of buy on book │ │ │ │ ack(o, status=PartiallyFilled, trades=[T1]) │ │ │ │◄───────────│ │ │ │ │ │ │◄──────│ │ │ │ │ │ │Stop order trigger (the Observer hook earning its keep):
MarketFeed Stock OrderBook StopOrder │ tick(price=49)│ │ │ │───────────────► │ │ │ │ notify(49) │ │ │ │─────────────►│ │ │ │ │ scan stops │ │ │ │ trigger? │ │ │ │ (sell stop@50: price<=50)│ │ │ │─────────────►│ │ │ │ activate │ │ │ │◄─────────────│ │ │ │ re-submit as MarketOrder │ │ │ │──► (back into match flow)│Cancel-during-match (the race the interviewer will probe):
Trader Service OrderBook Order(resting) │ cancel(id) │ │ │ │────────────►│ │ │ │ │ cancel(id) │ │ │ │────────────►│ │ │ │ │ lock book │ │ │ │ locate by id │ │ │ │ if status==Open or PartiallyFilled: │ │ │ remove from PQ │ │ │ │ o.cancel() │ │ │ │──────────────────────►│ │ │ │ else: error("filled / cancelled") │ │ ack/fail │ │ │◄────────────│ │ │The book is the single writer: submit and cancel both queue through it. There is no read-then-act race because the book holds the only mutable reference to its priority queues and to the order’s status during a match.
Activity diagram (for non-trivial state)#
The Order lifecycle:
┌─────────┐ │ start │ └────┬────┘ ▼ ┌────────────┐ ┌───────►│ Open │── full fill ────────────►┌────────────┐ │ └─────┬──────┘ │ Filled │ (terminal) │ partial │ └────────────┘ │ fill │ ▲ │ ▼ │ full fill │ ┌────────────────┐ │ │ │ PartiallyFilled│─────────────────────────────┘ │ └─────┬──────────┘ │ │ cancel / expire │ ▼ │ ┌────────────┐ ┌────────────┐ │ │ Cancelled │ │ Expired │ (terminal, GTD orders) │ └────────────┘ └────────────┘ │ cancel │ ┌────────────┐ │ Cancelled │ (terminal, from Open) └────────────┘Open -> Filled is legal (full fill on first cross). PartiallyFilled -> Cancelled is legal (cancel the rest after partial). Filled and Cancelled and Expired are terminal. No transition back to Open from PartiallyFilled — once a trade prints, the filled lot is locked.
StopOrder adds a pre-state Inactive that flips to Open on trigger:
┌────────────┐ trigger ┌────────────┐ │ Inactive │──────────────►│ Open │── (then the standard lifecycle) └────────────┘ └────────────┘Java implementation#
A representative slice: the order hierarchy, the order book, the price-time matching strategy, and the trade-application path. Portfolio mutation is sketched.
public enum Side { BUY, SELL }public enum OrderStatus { OPEN, PARTIALLY_FILLED, FILLED, CANCELLED, EXPIRED }
public abstract class Order { protected final String id; protected final String accountId; protected final String symbol; protected final Side side; protected final int quantity; protected int filledQty; protected OrderStatus status = OrderStatus.OPEN; protected final Instant ts;
protected Order(String id, String acct, String sym, Side s, int qty) { this.id = id; this.accountId = acct; this.symbol = sym; this.side = s; this.quantity = qty; this.ts = Instant.now(); }
/** Polymorphic: does this order cross the counterparty's resting price? */ public abstract boolean crosses(Money counterPrice);
/** Effective price for the cross (limit for limits, counter for markets). */ public abstract Money matchPrice(Money counterPrice);
public synchronized void fill(int qty, Money price) { if (status == OrderStatus.FILLED || status == OrderStatus.CANCELLED) { throw new IllegalStateException("fill on " + status); } if (qty <= 0 || qty > quantity - filledQty) { throw new IllegalArgumentException("over-fill"); } filledQty += qty; status = (filledQty == quantity) ? OrderStatus.FILLED : OrderStatus.PARTIALLY_FILLED; }
public synchronized void cancel() { if (status == OrderStatus.FILLED) { throw new IllegalStateException("cannot cancel filled"); } status = OrderStatus.CANCELLED; }
public int remaining() { return quantity - filledQty; } public OrderStatus status() { return status; } public Side side() { return side; } public Instant ts() { return ts; } public String id() { return id; }}
public final class MarketOrder extends Order { public MarketOrder(String id, String acct, String sym, Side s, int qty) { super(id, acct, sym, s, qty); } public boolean crosses(Money counterPrice) { return true; } public Money matchPrice(Money counterPrice) { return counterPrice; }}
public final class LimitOrder extends Order { private final Money limit; public LimitOrder(String id, String acct, String sym, Side s, int qty, Money limit) { super(id, acct, sym, s, qty); this.limit = limit; } public boolean crosses(Money counterPrice) { return side == Side.BUY ? counterPrice.lte(limit) : counterPrice.gte(limit); } public Money matchPrice(Money counterPrice) { return counterPrice; } public Money limit() { return limit; }}
public interface MatchingStrategy { List<Trade> match(NavigableSet<Order> bids, NavigableSet<Order> asks, Order incoming);}
public final class PriceTimePriorityMatching implements MatchingStrategy {
public List<Trade> match(NavigableSet<Order> bids, NavigableSet<Order> asks, Order incoming) { List<Trade> trades = new ArrayList<>(); NavigableSet<Order> book = (incoming.side() == Side.BUY) ? asks : bids;
while (incoming.remaining() > 0 && !book.isEmpty()) { Order best = book.first(); // Counterparty's resting price (for limits, that's their limit; for markets, n/a here). Money counter = (best instanceof LimitOrder lo) ? lo.limit() : best.matchPrice(null); if (!incoming.crosses(counter)) break;
int qty = Math.min(incoming.remaining(), best.remaining()); Money px = counter; // resting order sets the price
incoming.fill(qty, px); best.fill(qty, px);
String buyId = incoming.side() == Side.BUY ? incoming.id() : best.id(); String sellId = incoming.side() == Side.BUY ? best.id() : incoming.id(); trades.add(new Trade(UUID.randomUUID().toString(), incoming.symbol(), qty, px, buyId, sellId, Instant.now()));
if (best.remaining() == 0) book.pollFirst(); } return trades; }}
public final class OrderBook { private final String symbol; private final MatchingStrategy strategy; // Bids: highest price first; ties broken by earliest timestamp. private final NavigableSet<Order> bids = new TreeSet<>( Comparator.<Order, Money>comparing(o -> ((LimitOrder) o).limit()).reversed() .thenComparing(Order::ts).thenComparing(Order::id)); // Asks: lowest price first; ties broken by earliest timestamp. private final NavigableSet<Order> asks = new TreeSet<>( Comparator.<Order, Money>comparing(o -> ((LimitOrder) o).limit()) .thenComparing(Order::ts).thenComparing(Order::id));
private final Map<String, Order> byId = new HashMap<>(); private final List<TradeListener> listeners = new CopyOnWriteArrayList<>();
public OrderBook(String symbol, MatchingStrategy s) { this.symbol = symbol; this.strategy = s; }
public synchronized List<Trade> submit(Order o) { List<Trade> trades = strategy.match(bids, asks, o); if (o.remaining() > 0 && o instanceof LimitOrder) { (o.side() == Side.BUY ? bids : asks).add(o); byId.put(o.id(), o); } else if (o.remaining() > 0 && o instanceof MarketOrder) { // Unfilled market order is cancelled (no resting market orders). o.cancel(); } trades.forEach(t -> listeners.forEach(l -> l.onTrade(t))); return trades; }
public synchronized void cancel(String orderId) { Order o = byId.remove(orderId); if (o == null) throw new NoSuchElementException(orderId); (o.side() == Side.BUY ? bids : asks).remove(o); o.cancel(); }
public void addListener(TradeListener l) { listeners.add(l); }}Notes the interviewer will look for:
- Polymorphic crossing (
crosses(counterPrice)) — noinstanceofchain on order type during match. - The book is the single writer.
synchronizedonsubmitandcancel; the priority queues never escape. - Resting order sets the price. The incoming order is the taker; the counterparty’s price stands. This is a real exchange rule and a small detail that signals familiarity.
- Trades fan out to listeners for portfolio mutation, audit, and broadcast. Matching does not call portfolios directly — listeners do.
- Unfilled market orders cancel. No resting market orders on the book; the rule is enforced inside
submit. - Tie-breaking is deterministic (timestamp, then id). Two orders at the same price get a stable order across runs — critical for replay.
Trade-offs and extensions#
Doubles as the what-I-cut list — recite at the end of the round.
| Decision | Why | Cost / extension shape |
|---|---|---|
One OrderBook per symbol, single-writer | Synchronous matching keeps the model simple; concurrency lives at the book boundary. | At ~10x symbol load, shard books across threads (one per symbol or per shard); use lock-free NavigableSet alternatives. The book API stays. |
| Price-time priority hard-coded as default | The dominant rule on lit equity venues. | MatchingStrategy Strategy seam — swap in pro-rata (futures), call-auction (open / close), or hybrid. |
MarketOrder, LimitOrder, StopOrder, StopLimitOrder | The four shapes that cover >95% of retail volume. | Add IcebergOrder, TimeInForce (IOC, FOK, GTD) as additional polymorphic types. |
| Stops triggered by price tick (Observer) | Stops sit on the book inactive; the price tick activates them. | At ~10x symbol throughput, the tick listener becomes a hot path — index stops by trigger price (treemap of Money -> List<StopOrder>) instead of scanning. |
Portfolio mutation via TradeListener | Keeps matching pure (no portfolio dependency). | Listener becomes a durable consumer in a real broker (Kafka, queue). Same interface. |
| Cash account only | Margin is a separate domain (interest accrual, maintenance calls, forced liquidation). | MarginPolicy seam on Account; Order.preTradeCheck() consults it. Adds short-selling and borrow-locate. |
| One venue, no routing | OOD scope. | OrderRouter Strategy in front of BrokerService — chooses venue (or splits) before OrderBook.submit. |
| Instant settlement | Hide T+1 complexity. | SettlementEngine aggregate batches trades into nightly settlements; Position gains settledQty vs pendingQty. |
| Tax-lot = average cost | Simpler. | Position becomes a List<TaxLot>; CostBasisStrategy (FIFO, LIFO, specific-lot) computes realized P/L on sells. |
| No regulatory reporting | HLD round. | RegulatoryListener subscribes to trades; format adapters per jurisdiction (Reg NMS, MiFID II, SEBI). |
| In-process price feed | OOD round. | MarketDataFeed Publisher seam; the implementation is multicast UDP or a normalised feed handler at exchange scale. |
Scale breakpoints (where the design must change):
- At
~10xorder volume per symbol, the single-writer book becomes the bottleneck. The natural shard is per-symbol; symbols spread across threads. Cross-symbol orders (basket, pairs) need a coordinator. - At
~100xorder volume, the price-timeTreeSetis too slow — the in-memory structure becomes a custom limit-order book with arrays per price level and intrusive linked lists per level (this is how Nasdaq INET / LSE Millennium are built). - At multi-venue scale, the model gains an
OrderRouterand SOR strategy; books become per-venue and a virtual aggregate book is computed for display.
Likely follow-up extensions:
- Time-in-force.
Ordergainstif : TimeInForce(DAY, GTC, IOC, FOK, GTD). The book purges expired GTD orders on a timer. - Iceberg orders. Visible vs hidden quantity; the book only shows the visible slice and refreshes when filled.
- Auction / opening cross. A
CallAuctionMatchingStrategy that batches orders and crosses at a single uncrossing price. - Price alerts and watchlists. Customer subscribes to
Stock.priceTick; alerts fire on threshold cross. Observer. - Portfolio analytics. Realized / unrealized P/L per position; derived from trades + current price. Out of scope but the data is in the model.
Mock interview follow-ups#
- “Why is matching synchronous?” — One writer per book eliminates the entire class of read-then-act races. At retail scale, a
TreeSet-backed book handles>10^4orders/sec per symbol — the lock is not the bottleneck. When it becomes one, the shard is per-symbol, not per-thread. - “What’s the state machine on
Order?” — Show the activity diagram. EmphasiseOpen -> PartiallyFilled -> Filled(full fill of the rest) andPartiallyFilled -> Cancelled(cancel the rest).FilledandCancelledare terminal; no path back toOpen. - “Cancel races with a match — what happens?” — Both go through the book’s
synchronizedboundary; one wins. If the match wins first, the cancel sees statusFilledand errors. If the cancel wins, the order is removed from the queue and the next match skips it. - “How would stops work?” — Stop orders rest in a separate structure (not the bid/ask book) keyed by trigger price. On each
Stock.tick, the book scans stops on the relevant side; triggered stops becomeMarketOrders and re-entersubmit. Indexed by trigger price for~10xthroughput. - “Where does Observer fit, and where does it not?” — Observer fits outside matching: price ticks
->watchlists / alerts / stop triggers; trades->portfolio mutation / audit / broadcast. Observer does not fit inside matching — the match must be synchronous and ordered. - “Add a wallet / cash check on submit.” —
Order.preTradeCheck(account)runs beforebook.submit. For a buy:account.cash >= worstCasePrice * quantity. For a sell:position.quantity >= order.quantity(no shorts). Reject early. - “What did you cut and why?” — Recite the table. Margin, options, multi-venue routing, T+1 settlement, tax-lot accounting, regulatory reporting. All are real systems and none belong in an OOD round.
Related#
- ATM System — same family of state-machine-heavy financial-transaction modelling at a much smaller scope.
- Amazon Online Shopping System — the closest Advanced sibling: another saga over multi-aggregate state with explicit cuts.
- State Pattern —
Orderis a textbook state machine. - Strategy Pattern —
MatchingStrategyis the cleanest Strategy use in the workbook. - Observer Pattern — price ticks fan out to watchlists, alerts, and stop triggers.