Online Stock Brokerage System

Accounts, portfolios, market/limit/stop orders, matching, transactions. Order state plus price-time matching strategy.

System Advanced
17 min read
ood case-study brokerage state-pattern strategy-pattern

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 a type enum?
  • 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 Account owns one Portfolio. The portfolio tracks cash balance plus a map of Symbol to Position (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 OrderBook per symbol maintains two priority queues (bids descending, asks ascending) and runs a matching strategy to produce Trades.
  • 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 -> Filled or Cancelled or Expired.
  • 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 MarginPolicy seam is named.
  • Options, futures, derivatives. Equities only.
  • Multi-venue routing. One book per symbol. A real broker has an OrderRouter choosing 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 PriceTick is 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^4 symbols and ~10^6 orders 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 OrderBook has 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. The OrderBook does not know the rules — it knows it has bids, asks, and a strategy.
  • Strategy pattern on Order subtypes for crossing logic — a MarketOrder crosses any price, a LimitOrder crosses only at-or-better, a StopOrder becomes active only after a trigger. The Order.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 BrokerManager god class. The OrderBook owns matching; the Account owns its portfolio; nothing else needs to know.
  • No margin / loan / borrow ledger. Cut.
  • No Exchange aggregate. 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)) — no instanceof chain on order type during match.
  • The book is the single writer. synchronized on submit and cancel; 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.

DecisionWhyCost / extension shape
One OrderBook per symbol, single-writerSynchronous 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 defaultThe dominant rule on lit equity venues.MatchingStrategy Strategy seam — swap in pro-rata (futures), call-auction (open / close), or hybrid.
MarketOrder, LimitOrder, StopOrder, StopLimitOrderThe 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 TradeListenerKeeps matching pure (no portfolio dependency).Listener becomes a durable consumer in a real broker (Kafka, queue). Same interface.
Cash account onlyMargin 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 routingOOD scope.OrderRouter Strategy in front of BrokerService — chooses venue (or splits) before OrderBook.submit.
Instant settlementHide T+1 complexity.SettlementEngine aggregate batches trades into nightly settlements; Position gains settledQty vs pendingQty.
Tax-lot = average costSimpler.Position becomes a List<TaxLot>; CostBasisStrategy (FIFO, LIFO, specific-lot) computes realized P/L on sells.
No regulatory reportingHLD round.RegulatoryListener subscribes to trades; format adapters per jurisdiction (Reg NMS, MiFID II, SEBI).
In-process price feedOOD 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 ~10x order 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 ~100x order volume, the price-time TreeSet is 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 OrderRouter and SOR strategy; books become per-venue and a virtual aggregate book is computed for display.

Likely follow-up extensions:

  • Time-in-force. Order gains tif : 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 CallAuctionMatching Strategy 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^4 orders/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. Emphasise Open -> PartiallyFilled -> Filled (full fill of the rest) and PartiallyFilled -> Cancelled (cancel the rest). Filled and Cancelled are terminal; no path back to Open.
  • “Cancel races with a match — what happens?” — Both go through the book’s synchronized boundary; one wins. If the match wins first, the cancel sees status Filled and 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 become MarketOrders and re-enter submit. Indexed by trigger price for ~10x throughput.
  • “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 before book.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.
  • 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 PatternOrder is a textbook state machine.
  • Strategy PatternMatchingStrategy is the cleanest Strategy use in the workbook.
  • Observer Pattern — price ticks fan out to watchlists, alerts, and stop triggers.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.