Guide · System design
LLD: Design an Online Stock Brokerage, With Orders and Portfolios
20 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Stock brokerage LLD (low level design) means designing the classes inside a broker's trading app: accounts, orders, holdings and watchlists. The hard part is the rules that keep money and shares correct.
- Model each order as a state machine: a fixed list of states and the moves allowed between them. In this design, Filled, Cancelled, Rejected and Expired are final and never change again.
- Place a hold before an order leaves the app: move its value from free cash to blocked cash in one locked step. Without it, two orders can spend the same Rs 50,000.
- A fill is the exchange's message that some shares traded. Give each fill a unique key, apply it once, and update the order, cash, hold and holding together.
- Beginners can stop after the section on holds. Engineers get the tested parts: Java classes that run on Java 21, a race test, and a database hold checked on PostgreSQL 16.
What goes wrong when a trading app is built the simple way?
It spends the same money twice. The simple version reads the cash balance, compares it with the order value and sends the order. Two orders that arrive together both read the same balance, and both pass.
Picture a common setup for a stock brokerage LLD. An online broker has about 1.2 lakh (120,000) customers who buy and sell shares, track a portfolio and keep watchlists in one app. The setup is made up, but the problems are real.
On a busy morning, a customer with Rs 50,000 places two buy orders a second apart, each worth Rs 40,000. Both requests read Rs 50,000, both pass, and both go to the exchange.
The team finds three more problems in the same week.
- A fill message (the exchange saying some shares were bought) arrives twice after a network retry. The portfolio shows 20 shares instead of 10.
- A customer cancels an order just as it fills. The app shows Cancelled, but the shares really were bought.
- The watchlist screen and the order screen share one database. On a volatile morning, price updates make order placement take 4 seconds (made up).
All four bugs share one cause: no single place in the code owns the rule that would stop it. The rules are to spend money once, count a trade once, let the exchange decide an order's state and never make orders wait on prices. This guide redesigns the app class by class until every such rule has an owner.
What is stock brokerage LLD, and what should it cover?
Stock brokerage LLD (low level design) is the design of the classes, their fields and the rules between them inside a broker's trading app. It sits one level below the boxes-and-arrows view of servers and databases.
A broker is a firm with trading rights on a stock exchange that places orders there for its customers. The exchange runs the matching engine, the program that pairs buy orders with sell orders. On Indian exchanges, the broker does not match orders itself.
That split decides the scope. Matching engines and exchange-scale market data belong to stock trading system design, a separate topic. A brokerage LLD covers what happens on the broker's side of the wire.
- Customers and accounts: who can trade, and how much cash and how many shares they have.
- Orders: placing and cancelling them, and tracking their state.
- Risk checks and holds: making sure an order can be paid for before it leaves.
- Fills and portfolio: applying each trade to cash, holdings and average price.
- Watchlists: lists of stocks a customer follows, with live prices.
Out of scope, unless the interviewer asks: login, charts, research reports, order changes (modify) and the exchange's own matching. Saying this out loud in an interview shows you can draw a boundary.
What classes do you need for an online stock brokerage?
You need about eight core classes: Customer, Account, Instrument, Order, Fill, Holding, Portfolio and Watchlist, plus a few services that use them. Each class owns one kind of fact.
Instrument is the catalogue entry for one tradable stock. It holds the symbol, the exchange, the tick size (the smallest price step the exchange allows) and whether trading is allowed today. Every order points to one Instrument, and the app rejects unknown symbols.
Here is how the classes connect. Notice that Order never changes cash directly. Only Account does, which is what fixes the double-spend bug later.
Core classes of the brokerage app
Drag to exploreEach class owns one kind of fact. Follow the arrows from Customer down: Order points at an Instrument and receives Fills, but only Account changes cash or holds.
Diagram connections:
- Customer to Account (has)
- Customer to Watchlist (keeps)
- Account to Order (places)
- Order to Instrument (refers to)
- Order to Fill (receives)
- Account to Portfolio and Holding (owns)
- Watchlist to Instrument (lists)
- Customer: Name, identity check, one or more accounts
- Account: Free cash, blocked cash, applied trade keys
- Order: Side, type, quantity, hold price, status
- Instrument: Symbol, exchange, tick size, tradable flag
- Fill: Trade id, quantity and price from exchange
- Portfolio and Holding: Shares per symbol and total cost
- Watchlist: Named list of symbols to follow
Around these sit a few services: classes that hold no data of their own but run the steps. OrderService places and cancels orders. RiskService checks and blocks cash or shares. FillProcessor takes fills from the gateway and hands each one to the right Account. PriceFeed pushes live prices.
The exchange itself hides behind an interface called ExchangeGateway. An interface is a list of methods with no code inside, such as send and cancel. Tests use a fake gateway, and production uses the real one.
How do you model the life of a stock order?
You model it as a state machine: a fixed list of states, and a fixed list of moves allowed between them. Any move outside the list is a bug, and the code refuses it.
FIX, the common message standard between brokers and trading venues, has a field called OrdStatus for this. Its values in FIX 4.4 include Pending New, New, Partially Filled, Filled, Pending Cancel, Canceled, Rejected and Expired. This app uses similar names.
- PENDING_NEW: saved and checked by the app and sent, but the exchange has not replied yet.
- OPEN: the exchange accepted it, and it waits in the exchange's order book (its list of waiting orders).
- PARTIALLY_FILLED: some shares traded, and the rest still wait.
- PENDING_CANCEL: the customer asked to cancel, and the exchange has not confirmed.
- FILLED, CANCELLED, REJECTED, EXPIRED: final states. Nothing moves an order out of them.
Start at Pending new in the diagram and follow the arrows. Look closely at Pending cancel. It can still end in Filled, because the exchange may fill the order before the cancel arrives.
States of one order
Drag to exploreFour states are final and have no arrows out. Pending cancel is not final: a fill can still win the race, and a refused cancel sends the order back.
Diagram connections:
- Pending new to Exchange accepts?
- Exchange accepts? to Open (Yes)
- Exchange accepts? to Rejected (No)
- Open to Partially filled (Some shares trade)
- Partially filled to Filled (Last shares trade)
- Open to Pending cancel (Customer cancels)
- Partially filled to Pending cancel (Customer cancels)
- Pending cancel to Cancelled or expired (Cancel confirmed)
- Pending cancel to Filled (Fill arrives first)
- Pending cancel to Partially filled (Cancel refused)
- Open to Cancelled or expired (Day ends or IOC rest)
- Partially filled to Cancelled or expired (Day ends)
- Pending new: Saved, held, sent to exchange
- Exchange accepts?: First reply from the exchange
- Open: Waiting in the exchange's order book
- Partially filled: Some shares traded, the rest wait
- Pending cancel: Cancel sent, not yet confirmed
- Filled: Final: all shares traded
- Cancelled or expired: Final: rest of the hold is released
- Rejected: Final: full hold is released
The rules live inside the state type itself, as a Java enum. An enum is a type with a fixed set of named values. Each value answers one question: can I move to that state next?
enum OrderStatus {
PENDING_NEW, OPEN, PARTIALLY_FILLED, PENDING_CANCEL,
FILLED, CANCELLED, REJECTED, EXPIRED;
boolean canMoveTo(OrderStatus next) {
return switch (this) {
case PENDING_NEW -> next == OPEN || next == REJECTED;
case OPEN, PARTIALLY_FILLED -> next == PARTIALLY_FILLED
|| next == FILLED || next == PENDING_CANCEL
|| next == CANCELLED || next == EXPIRED;
case PENDING_CANCEL -> next == CANCELLED || next == FILLED
|| next == OPEN || next == PARTIALLY_FILLED;
default -> false; // final states never move
};
}
boolean isFinal() {
return this == FILLED || this == CANCELLED
|| this == REJECTED || this == EXPIRED;
}
}Two branches carry the design. The default branch locks all four final states, so a late message cannot reopen a filled order. The PENDING_CANCEL branch allows a return to OPEN or PARTIALLY_FILLED, for when the exchange refuses the cancel because it came too late.
The Pending cancel and Pending new moves match the order state change matrix in Appendix D of the FIX specification (FIX 5.0 SP2). Its table shows Filled, Canceled, Partially Filled and New as next states after Pending Cancel. The matrix has no Expired column, so the moves into EXPIRED are this design's choice.
The same table shows only New and Rejected after Pending New, and it notes that a move it does not show is not necessarily forbidden. So this enum assumes the acceptance arrives before any fill. If your gateway cannot promise that, also allow PENDING_NEW to move to PARTIALLY_FILLED and FILLED.
Two honest limits. FIX also describes cancelling an order before it is acknowledged; this enum refuses that, and the app asks the customer to retry. FIX tables also show a Filled order going back to Partially Filled when a trade is busted (cancelled) or corrected.
This app keeps final states final and handles those rare busts as a separate, audited adjustment. Say so in an interview rather than claiming a filled order can never change anywhere.
Which order types does the design need?
Order type and validity decide how much to block and when an order ends. The definitions below follow the National Stock Exchange (NSE) page on its equity trading system, as of 19 October 2026.
| Order type | What it means | What the app blocks for a buy |
|---|---|---|
| Limit | You specify the price when you enter the order; a buy will not trade above it | Quantity times the limit price |
| Market | Trades at the best price available when it is entered | Quantity times the last price plus a 5 percent buffer |
| Stop loss | Waits until the last traded price reaches or crosses a trigger price, then enters as a limit order | Quantity times the limit price, from the moment it is placed |
| Day validity | If not matched during the day, cancelled automatically at the end of the trading day | Hold lasts until fill, cancel or day end |
| IOC validity | Immediate or cancel: matches what it can at once, and the rest is cancelled immediately | Hold lasts only until the exchange replies |
Each type has its own checks. A limit price must be a multiple of the tick size, and a stop loss order needs a trigger price as well as a limit price. Each type gets its own small validator class behind one OrderRule interface.
This is the Strategy pattern: one interface, several interchangeable classes, picked at run time by the order type. Adding a new order type means adding one class, not editing a long if-else chain.
How does a broker block cash and shares before an order is sent?
The broker places a hold: it moves the order's value from free cash to blocked cash, in one locked step, before the order leaves. A second order sees only what is still free.
Think of the card block many hotels place at check-in. Your available limit drops at once, though no money has moved yet. At checkout the hotel charges the real bill and the rest of the block is freed.
Map it part by part. The card block is the hold. Your available limit is free cash. The final bill is the fill at the real price, and the freed remainder is the release. Where the picture breaks: a card block is one amount, while a broker's hold shrinks share by share as partial fills arrive, and nothing frees it except your code.
So Account keeps two numbers: freeCashPaise and blockedCashPaise. A buy order for 100 shares with a limit of Rs 400 blocks Rs 40,000. A sell order blocks 100 shares in the holding instead of cash. Charges are left out here to keep the numbers clear.
Replay the busy morning with holds. The first order blocks Rs 40,000, leaving Rs 10,000 free. The second order asks for Rs 40,000, sees Rs 10,000, and is rejected with a clear message before it reaches the exchange.
Here is one buy order from tap to exchange. Follow the boxes in order, and note where the order is saved: before it leaves the app, not after.
Placing one buy order
Drag to exploreThe hold and the saved order come before the exchange call. After a crash, every order that might be at the exchange is already on record.
Diagram connections:
- Tap Buy 100 at Rs 400 to Validate the order
- Validate the order to Enough free cash? (Valid)
- Enough free cash? to Reject with reason (No)
- Enough free cash? to Block cash, save order (Yes)
- Block cash, save order to Send to exchange
- Send to exchange to Mark open (Accepted)
- Tap Buy 100 at Rs 400: App → OrderService
- Validate the order: OrderService → OrderRule
- Enough free cash?: RiskService → Account
- Reject with reason: OrderService → App
- Block cash, save order: Account → Orders table
- Send to exchange: ExchangeGateway → Exchange
- Mark open: Exchange → OrderService
The app also creates a client order id, a unique id for each order, when the customer taps Buy. Every retry of that request carries the same id. OrderService finds it and returns the existing order, so a retry does not place a second order. Store the id under a unique key, so two retries arriving together cannot both create one.
How do you make the hold safe in code and across servers?
Inside one server, make every method that touches an account's money synchronized on that Account object. A thread is one line of work running inside the program. Java then lets only one thread at a time run any synchronized method of that same object. Send every change to an order's state through its Account too, so a cancel request cannot interleave with a fill.
The Account class below holds the money rules. tryHold checks and blocks in one step. onBuyFill applies a fill, explained in the next section. close frees the rest of the hold when an order reaches a final state after it was sent.
class Account {
long freeCashPaise;
long blockedCashPaise;
final Set<String> appliedTradeKeys = new HashSet<>();
final Map<String, Holding> holdings = new HashMap<>();
Account(long freeCashPaise) {
this.freeCashPaise = freeCashPaise;
}
synchronized boolean tryHold(Order o) {
long amountPaise = o.quantity * o.holdPricePaise;
if (amountPaise > freeCashPaise) {
return false; // reject before the order leaves the app
}
freeCashPaise -= amountPaise;
blockedCashPaise += amountPaise;
return true;
}
synchronized void onBuyFill(Order o, String tradeKey, long qty, long pricePaise) {
if (appliedTradeKeys.contains(tradeKey)) {
return; // same fill sent twice
}
o.recordFill(qty); // state moves forward, or throws
long heldPaise = qty * o.holdPricePaise;
long costPaise = qty * pricePaise;
blockedCashPaise -= heldPaise;
freeCashPaise += heldPaise - costPaise; // a better price comes back
holdings.computeIfAbsent(o.symbol, s -> new Holding()).buy(qty, costPaise);
appliedTradeKeys.add(tradeKey);
}
synchronized void close(Order o, OrderStatus finalStatus) {
if (!finalStatus.isFinal() || finalStatus == OrderStatus.FILLED) {
throw new IllegalArgumentException("cannot close as " + finalStatus);
}
o.moveTo(finalStatus); // a final state can never be closed twice
long restPaise = (o.quantity - o.filledQuantity) * o.holdPricePaise;
blockedCashPaise -= restPaise;
freeCashPaise += restPaise;
}
}We ran two threads that each try to hold Rs 40,000 from Rs 50,000, 20,000 times on Java 21. Exactly one hold succeeded every time. Without synchronized, and with a short pause added between check and change, both succeeded in hundreds of the 2,000 runs. The count changes every run: from 449 to 1,627 in our tests.
close accepts only CANCELLED, REJECTED or EXPIRED, so a cancel request cannot free the hold early. Only fills make an order FILLED. It then calls moveTo first. Because a final state cannot move again, a second close throws before any money moves, so a hold cannot be released twice. The state machine guards the money.
What changes when the app runs on many servers?
synchronized protects one object in one process. With several app servers, each has its own copy of the Account object, so the lock no longer covers both orders. Move the same check into the database.
The statement below blocks Rs 40,000 only if free cash is still at least that much. The database reports how many rows changed. One row means the hold is placed, and zero rows means reject the order. Run it in the same transaction, a group of writes that either all happen or none do, as the insert that saves the order.
UPDATE accounts
SET free_cash_paise = free_cash_paise - 4000000,
blocked_cash_paise = blocked_cash_paise + 4000000
WHERE account_id = 42
AND free_cash_paise >= 4000000;We tested this on PostgreSQL 16.15 at its default isolation level, Read Committed. An isolation level is the rule for how much one transaction sees of others running at the same time. The second update waited for the first to commit, then re-checked the WHERE condition on the new row and changed zero rows. PostgreSQL's documentation describes this re-check.
At Repeatable Read, a stricter level, the second update failed with a serialization error instead. That is PostgreSQL refusing the update, so the app must retry that transaction.
Across 500 rounds of two truly parallel connections, exactly one hold succeeded each time. We also ran the naive version 300 times: it reads the balance in one statement and writes it in another. Both orders got through in 297 rounds, and in 299 on a re-run.
How does a fill update the portfolio and the average price?
A fill updates four things together: the order's filled quantity and state, the hold, the cash and the holding. If any one of them changes alone, the customer sees numbers that do not add up.
Every fill carries the exchange's trade id, but a trade id can name a whole match: a buy side and a sell side, and both can be your customers. So build the idempotency key, an id that lets you spot a repeat, from the exchange, the trading day, the exchange's order id and the trade id. The trading day keeps it unique even if numbering restarts each day.
Here is the Order class that onBuyFill calls. recordFill works out the next state first and only then changes anything, so an impossible fill throws before any number moves.
class Order {
final String symbol;
final long quantity;
final long holdPricePaise; // limit price, or last price plus buffer
long filledQuantity;
OrderStatus status = OrderStatus.PENDING_NEW;
Order(String symbol, long quantity, long holdPricePaise) {
this.symbol = symbol;
this.quantity = quantity;
this.holdPricePaise = holdPricePaise;
}
void moveTo(OrderStatus next) {
if (!status.canMoveTo(next)) {
throw new IllegalStateException(status + " cannot move to " + next);
}
status = next;
}
void recordFill(long qty) {
long total = filledQuantity + qty;
if (total > quantity) {
throw new IllegalStateException("fill is bigger than the order");
}
OrderStatus next = total == quantity ? OrderStatus.FILLED
: status == OrderStatus.PENDING_CANCEL ? OrderStatus.PENDING_CANCEL
: OrderStatus.PARTIALLY_FILLED;
if (next != status) {
moveTo(next); // throws before anything changes
}
filledQuantity = total;
}
}Now read onBuyFill in the Account class above, line by line. A trade key already applied returns at once, which is what stops the 20-share bug. recordFill moves the state forward or throws.
Then the hold shrinks by the hold price times the quantity, and cash drops by the real price. A limit order can fill at a better price, so the difference goes back to free cash. A fill during Pending cancel keeps that state until the exchange decides.
A sell fill mirrors this. It frees the blocked shares, calls Holding.sell to work out the profit, and credits the sale value to the account.
A market order holds the last price plus the buffer. If it fills above that, free cash can go below zero. In our test, a hold of Rs 420 a share that used all free cash filled at Rs 430, leaving minus Rs 1,000. Flag that case; do not assume it cannot happen.
In a real system, the fill, the order, the account and the holding are saved in one database transaction, so they change together. The fills table gets a unique key on exchange, trading day, exchange order id and trade id.
With that key, INSERT ... ON CONFLICT DO NOTHING tells you whether the fill is new. On PostgreSQL 16 a repeat reported 0 rows, while the other side of the same trade still reported 1 row. A key without the order id dropped that second side. Zero rows means skip the rest of the transaction.
How is the average price worked out?
A Holding keeps total quantity and total cost. The average price is total cost divided by quantity, which is the weighted average cost method. Buys change the average, and sells do not.
Start
A customer holds 10 shares of one stock, bought for Rs 2,000 in all. The average price is Rs 200.
Buy 5 more at Rs 230
Total cost becomes Rs 2,000 plus Rs 1,150, so Rs 3,150 for 15 shares. The new average price is Rs 210.
Sell 6 at Rs 250
The average stays Rs 210. The remaining 9 shares carry a cost of Rs 1,890. The realised profit, the gain locked in by selling, is 6 times Rs 40, so Rs 240.
Show the result
The portfolio screen shows 9 shares, an average of Rs 210, and Rs 240 in the day's realised gains.
class Holding {
long quantity;
long totalCostPaise;
void buy(long qty, long costPaise) {
quantity += qty;
totalCostPaise += costPaise;
}
long sell(long qty, long pricePaise) { // returns realised profit
long costOfSoldPaise = totalCostPaise * qty / quantity;
quantity -= qty;
totalCostPaise -= costOfSoldPaise;
return qty * pricePaise - costOfSoldPaise;
}
long averagePaise() {
return quantity == 0 ? 0 : totalCostPaise / quantity;
}
}sell removes cost in proportion to the shares sold, rather than subtracting a rounded average. Subtracting a rounded average can leave cost behind: buy 3 shares for 100 paise, sell them 1 and then 2, and 1 paisa of cost remains with zero shares. Tax reports may need a different method, such as first in, first out, so store every buy as its own lot too.
Why are bought shares unsettled at first?
A trade is agreed at once, but money and shares change hands later, at settlement. India's equity cash market has settled on T+1, one working day after the trade, since January 2023.
SEBI, India's market regulator, started an optional same-day T+0 cycle in March 2024 for 25 stocks. A SEBI circular of 10 December 2024 widened it to the top 500 companies by market value, in phases, alongside T+1. As of 19 October 2026, the latest SEBI circular we found (30 October 2025) had again postponed the date for large brokers to offer it, with no new date set.
So Holding should keep settled and unsettled quantities apart; the sketch above leaves this out. A settlement job runs each working day and moves settled shares across. The screen shows both, so nobody panics when bought shares seem to be missing.
How can you check that this stock brokerage design works?
Run the classes together. Save the main class below first in a file named BrokerageDemo.java, then paste the OrderStatus, Order, Account and Holding blocks under it. Run java BrokerageDemo.java on Java 21.
import java.util.*;
public class BrokerageDemo {
public static void main(String[] args) throws Exception {
Account acct = new Account(5_000_000); // Rs 50,000 in paise
Order a = new Order("STOCK", 100, 40_000); // 100 shares at Rs 400
Order b = new Order("STOCK", 100, 40_000);
boolean[] ok = new boolean[2];
Thread t1 = new Thread(() -> ok[0] = acct.tryHold(a));
Thread t2 = new Thread(() -> ok[1] = acct.tryHold(b));
t1.start(); t2.start(); t1.join(); t2.join();
int placed = (ok[0] ? 1 : 0) + (ok[1] ? 1 : 0);
System.out.println("holds placed: " + placed + " of 2");
System.out.println("free " + acct.freeCashPaise
+ " paise, blocked " + acct.blockedCashPaise + " paise");
Order o = ok[0] ? a : b;
o.moveTo(OrderStatus.OPEN);
acct.onBuyFill(o, "NSE-2026-10-19-ORD1-T1", 60, 39_800);
acct.onBuyFill(o, "NSE-2026-10-19-ORD1-T1", 60, 39_800); // a repeat
acct.onBuyFill(o, "NSE-2026-10-19-ORD1-T2", 40, 39_900);
Holding h = acct.holdings.get("STOCK");
System.out.println(o.status + ", " + h.quantity
+ " shares, average " + h.averagePaise() + " paise");
System.out.println("free " + acct.freeCashPaise
+ " paise, blocked " + acct.blockedCashPaise + " paise");
try {
acct.close(o, OrderStatus.CANCELLED); // a late cancel message
} catch (IllegalStateException e) {
System.out.println("refused: " + e.getMessage());
}
Holding demo = new Holding();
demo.buy(10, 200_000); // 10 shares for Rs 2,000
demo.buy(5, 115_000); // 5 more at Rs 230
long profit = demo.sell(6, 25_000); // sell 6 at Rs 250
System.out.println(demo.quantity + " shares, average "
+ demo.averagePaise() + " paise, profit " + profit + " paise");
}
}You should see exactly these six lines. All amounts are in paise, so 1000000 paise is Rs 10,000 and 39840 paise is Rs 398.40.
holds placed: 1 of 2
free 1000000 paise, blocked 4000000 paise
FILLED, 100 shares, average 39840 paise
free 1016000 paise, blocked 0 paise
refused: FILLED cannot move to CANCELLED
9 shares, average 21000 paise, profit 24000 paiseWhat just happened? Two threads raced for the same cash, and only one hold was placed. The repeated fill was skipped, so 60 plus 40 shares made exactly 100. The better prices returned Rs 160 to free cash, and a late cancel was refused.
The last line replays the average price steps: 9 shares at Rs 210 and Rs 240 profit. If your numbers differ, check that every method touching money is synchronized and that the duplicate trade key is skipped.
How do you design watchlists and live prices without slowing orders?
Give watchlists their own read path: a price cache that a price feed keeps fresh, and a push channel to phones. The order path never waits on it, and it never writes to the orders database.
The Watchlist class is small. It has an id, an owner, a name such as Banking picks, and an ordered list of symbols. This app caps it at 50 symbols per list and 10 lists per customer (made up).
Live prices use the Observer pattern. A subject keeps a list of observers and tells each one when something changes. Here PriceFeed is the subject, and each open watchlist screen observes the symbols it shows.
Watchlist price path
Drag to explorePrices have their own path from the feed to phones. Notice that no arrow touches the order service or the orders database.
Diagram connections:
- Exchange price feed to PriceFeed service (ticks)
- PriceFeed service to Latest price cache (latest price)
- Latest price cache to Push service (reads)
- Watchlist store to Push service (who watches what)
- Push service to Watchlist screen (updates)
- Exchange price feed: Every trade and quote change
- PriceFeed service: Keeps only the latest price per symbol
- Latest price cache: One entry per symbol, read by all
- Push service: Sends prices of watched symbols only
- Watchlist store: Lists and symbols per customer
- Watchlist screen: Subscribes to its own symbols
The push service sends only the newest price per symbol and drops the older ones, a trick called conflation. If a symbol ticks 10 times in a second and the service sends every 500 milliseconds (made up), the phone gets 2 messages instead of 10.
One rule keeps money safe. RiskService never trusts the price shown on the phone. For a market order it reads the latest price from the cache, adds the buffer and blocks that amount.
What can go wrong in a stock brokerage design?
Most failures come from timing: two things happen at once, a message repeats, or a reply never arrives. Each one needs a rule written into a class.
- Two orders spend the same cash. Guard: one synchronized hold per account, or a conditional update in the database.
- A fill arrives twice. Guard: a unique key on exchange, trading day, exchange order id and trade id, so a repeat changes nothing and the other side still counts.
- A fill and a cancel cross. Guard: Pending cancel can still move to Filled, and the screen waits for the exchange.
- The exchange never replies to a new order. Guard: keep it Pending new, ask the exchange for its status, and never resend blindly.
- A day order is never closed in the app. Guard: an end-of-day job asks the exchange for open orders, closes the rest and frees their holds.
- Fills arrive out of order. Guard: apply each by its own quantity, and check against the total filled quantity the exchange reports (FIX calls it CumQty).
Compliance adds one more rule. Every change to an order, a hold or a holding writes an audit row: who, what, when, and the old and new values. Audit rows are only ever added, never edited or deleted.
Each day, a reconciliation job compares the app's records with the trade files from the exchange and the clearing corporation, the body that settles the exchange's trades. Any mismatch goes to the operations team before the next market open.
How do you explain stock brokerage LLD in a system design interview?
Explain it as the story of one order, and add each class when the story needs it. This keeps you from listing classes nobody asked for.
Set the scope
Say the exchange matches orders, and you design the broker side: accounts, orders, holds, fills, portfolio and watchlists. Ask which order types to support.
Draw the core classes
Customer, Account, Instrument, Order, Fill, Holding, Portfolio and Watchlist. Give each one or two key fields, with money stored as paise.
Walk through one buy order
Validate, block cash, save, send, mark open. Show the client order id that makes retries safe.
Show the state machine
Draw the states and the allowed moves. Point at Pending cancel and explain the race with a late fill.
Apply a fill
Use the trade key, which includes the order id, to ignore repeats. Update order, hold, cash and holding in one transaction, and show the average price maths.
Scale the hold
Say synchronized covers one server only. Move the check into a conditional database update for many servers.
Close with patterns and failures
Name State, Strategy and Observer in one sentence each. Then cover a lost reply, day-end expiry, settlement and daily reconciliation.
If time is short, protect steps three to six. The hold, the state machine and the fill are where interviewers look for real understanding.
Questions people ask
- Is a stock brokerage LLD the same as designing a stock exchange?
- No. A stock exchange design centres on the matching engine and market data at huge scale. A brokerage LLD centres on the broker's side: accounts, orders, holds, fills, portfolios and watchlists. The broker sends orders to the exchange and applies the results it gets back.
- Why store money in paise instead of a double in Java?
- A double cannot store most decimal fractions exactly, so 0.1 + 0.2 prints 0.30000000000000004 in Java. A long count of paise is exact and fast. BigDecimal also works, but compare its values with compareTo, because equals also checks the number of decimal places.
- How do you handle a partial fill in the design?
- Treat each partial fill as its own event with its own trade key. Add its quantity to the order's filled quantity, free that part of the hold and update the holding. The order stays Partially filled until the last shares trade or the rest is cancelled.
- Is synchronized enough to stop double spending?
- Only inside one process with one Account object per account. With several servers, each holds its own copy, so put the check in the database. A conditional UPDATE that changes the row only if free cash is enough worked on PostgreSQL 16 in our test.
- What happens if a customer cancels an order that fills at the same moment?
- The fill wins if it reached the exchange first. The order moves to Pending cancel, and the exchange then sends either a fill with a cancel rejection, or a cancel confirmation. The app must accept a fill in Pending cancel and show the order as Filled.
- What is the difference between holdings and positions?
- Holdings are shares you own and keep across days in a demat account, the electronic account that holds your shares. Positions are trades made today, including intraday trades you plan to close before the market shuts.
- Which design patterns fit a stock brokerage LLD?
- Three fit naturally. The State pattern, or a state enum, controls how orders move between states. The Strategy pattern gives each order type its own validator. The Observer pattern pushes live prices to watchlist screens. An interface for the exchange gateway also makes the design easy to test.
Sources and further reading
- NSE India: Equity market trading system (order books, order types and validity)
- FIX 5.0 SP2, Appendix D: Order State Change Matrices (online FIX dictionary rendering)
- FIX 4.4 OrdStatus (39) field values (online FIX dictionary)
- FIX 4.4 CumQty (14) field definition (FIX Trading Community Orchimate)
- FIX TrdMatchID (880) field definition: one match event results in multiple executions (FIX Trading Community Orchimate)
- SEBI Board memorandum: Enhancement of scope of optional T+0 settlement cycle (2024; T+1 since January 2023, T+0 beta of March 2024)
- SEBI Board decisions: optional T+0 widened from 25 to the top 500 scrips (published December 2024; circular dated 10 December 2024)
- SEBI circular, 30 October 2025: further extension of timeline for Qualified Stock Brokers to offer optional T+0
- PostgreSQL 16 documentation: Transaction Isolation, Read Committed (UPDATE re-checks WHERE on the new row)
- The Java Tutorials: Synchronized Methods
- Java SE 21 API documentation: java.math.BigDecimal (equals vs compareTo)