How low-level and high-level design differ
Open in the interactive app: practice, quiz, mock interview →In one minute
- High-level design (HLD) decides which components exist (services, datastores, queues, caches), what each owns, and the contracts between them. Low-level design (LLD) decides how one component is built inside: classes, interfaces, state, invariants and errors.
- The split shows in cost and failure: a call inside an LLD costs nanoseconds and returns or throws; a call across an HLD boundary costs about a millisecond, can time out, run twice or see stale data.
- They meet at the interface. HLD fixes the shape of an API (
GET /orders?cursor=&limit=returns items and anext_cursor) and the promises around it; LLD chooses the data structure, the cursor encoding and the error messages that keep those promises. - Most bad designs are a level mismatch: an HLD that names classes but no failure modes, or an LLD that quietly changes a contract the HLD fixed.
- In review and with coding agents, give each level its own document and its own tests: contract tests at the boundary, unit tests inside.
How it works
Two questions at two zoom levels
Take an online shop's order history page. The HLD asks: does the orders service own its Postgres database? Are reads served from a replica, behind a cache? Is the public API paginated by page number or by cursor? What does the page show when the database is down?
The LLD of the orders service asks: is there an OrderFeed class, or does the handler build SQL? What is inside a cursor? Which object rejects a limit of 0, and with what message? What does "newest first" mean when two orders share a timestamp?
Both are design. They differ in the unit they reason about (a service versus a class), the failures they plan for (a region outage versus an invalid argument), and the people who read them (several teams versus the team that owns the code).
What each level produces
An HLD contains a component diagram, read and write data flows, a data ownership table, capacity estimates, the external API, consistency and availability targets, and a failure analysis. The System Design Interviews course (What are System Design Interviews?, Answering Framework) works at this level.
An LLD contains entities and relationships, the public methods of each class with their preconditions and errors, invariants, lifecycle state machines and the call sequence of the main flows. What is LLD? walks through one end to end; Class Diagram, Sequence Diagram and State Machine Diagram are its notations.
The interface is where they meet
The two levels are joined by contracts. The HLD says "the orders API uses cursor pagination; a client that pages through the full history while new orders arrive sees every order that existed when it started exactly once." That sentence is an HLD decision because it affects clients, caches and the database's index design. It is also a requirement on the LLD, which now has to pick a cursor that survives inserts and deletes.
If the LLD implements the cursor as an offset (page=3 means skip 40 rows), it breaks the promise: a new order at the top shifts every row down by one and the client sees one order twice. Nothing in the class diagram looks wrong. The bug is a level mismatch, and the fix is a keyset cursor that encodes the last seen sort key, which the Practice section builds.
Which decisions belong where
A useful test: a decision is high level if changing it later requires coordination between teams or a data migration, and low level if one team can change it in one deploy without anyone outside noticing. Kafka versus a database outbox is high level; a dict versus a sorted list inside a service is low level. Some decisions start low and become high: an error message is low level until clients parse it, and then it is part of the contract. That is why HLD documents should list the observable behaviour (status codes, error codes, ordering, idempotency) and LLD documents should mark which of their choices are visible outside.
In interviews
System design interviews ask for HLD: scale, components, trade-offs. LLD interviews ask for classes and working code for one component, as in Design Parking Lot, Design LRU Cache and Design Rate Limiter; Types of LLD interviews describes the formats. The common mistake is the wrong zoom level: classes in an HLD round, load balancers in an LLD round. State the level out loud ("storage is a repository interface; I'll focus on booking logic") and move between levels deliberately.
Reviewing agent-written code
Coding agents work at LLD scale: they read a few files and change classes. Asked to "add pagination to the orders endpoint", an agent will usually add offset and limit, the most common pattern, and its unit tests will pass. The HLD promise about stable paging lives in a document it never opened. Put the contract where the agent will see it: the interface's doc comment and a contract test that inserts a row between two page fetches.
The same shape elsewhere
Two levels that must agree is a multi-step consistency problem. In networking, TCP's RFC 9293 is the high-level contract and the Linux stack's struct sock and timers are one low-level design that satisfies it. In building, the architect places load-bearing walls and the structural engineer sizes the beams. The expensive mistakes come from the lower level breaking a promise the upper level made.
Down the stack
Layer 1The contract
The HLD-to-LLD contract for the orders feed can be written so that it is testable. Ordering is total: newest created_at first, ties broken by higher id. A page returns at most min(limit, max_limit) items and a next_cursor, which is empty exactly when no later item exists. Paging from an empty cursor to the end returns every order that existed for the whole walk exactly once, in order; orders inserted during the walk may or may not appear but never cause a duplicate or a skip; a deleted order disappears without shifting others. A malformed cursor or a limit below 1 is a client error (HTTP 400 with a stable error code), not a 500. Each of these sentences is one test.
Layer 2The mechanism
The LLD keeps the contract with a keyset. The sort key (created_at, id) is unique because id is. The cursor encodes the last key returned, for example c1:1718000000:4821, with a version prefix. The next page is the first limit keys strictly less than the cursor key. In Postgres this is a row-value comparison, WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3, served by a composite B-tree index on (created_at, id) (scoped by customer, in practice (customer_id, created_at, id)). Fetching limit + 1 rows tells you whether a next page exists without a second query. In memory, a sorted array with bisect or sort.Search finds the cursor's position in O(log n).
The cursor is opaque by contract: clients must not build or parse it. That gives the LLD freedom to change the encoding (adding a shard id, or signing it with an HMAC so clients cannot forge positions) without an HLD change.
Layer 3The machine
A method call inside a service costs 1 to 5 ns (a few more through a Go itab or C++ vtable), a cache miss about 100 ns. A round trip to another service in the same data centre is 0.2 to 1 ms, and across regions 30 to 150 ms. That gap is why HLD worries about chattiness, batching and timeouts while LLD worries about allocation and readability.
The pagination choice shows up on disk. OFFSET 100000 LIMIT 20 makes Postgres walk the index and fetch and discard 100,000 rows before returning 20; with 8 KB heap pages and rows of about 200 bytes, that can be 2,500 page reads for one request, and deep pages get linearly slower. The keyset query descends the B-tree in 3 or 4 page reads (a tree of fanout around 300 holds tens of millions of keys in 3 levels) and reads 21 rows. Invisible at page 1, decisive at page 5,000, where export jobs live.
Why it's built this way
- Teams, networks and datastores fail and change independently, so the system needs a map of components and contracts that outlives any one implementation; that map is the HLD.
- Inside a component, change is cheap and failures are local, so the useful questions are about state, invariants and extensibility; that is the LLD.
- The two must agree, but the people and the time scales differ, so they agree through explicit interfaces rather than shared knowledge.
- Interfaces are only as good as their tests, so contract tests sit at the boundary and run against every implementation.
- Network calls cost a million times more than method calls, so where to draw a boundary is itself an economic decision, made at the HLD level and paid for in every LLD below it.
Diagram
flowchart TD R["Requirement: stable order history paging"] --> H["HLD: orders service owns Postgres, cursor pagination"] H --> C["Contract: GET /orders?cursor&limit returns items and next_cursor"] C --> T["Contract tests: insert or delete between pages"] C --> L["LLD: OrderFeed, keyset (created_at, id), opaque cursor"] L --> I["Index: (customer_id, created_at, id)"] L --> U["Unit tests: parsing, limits, ties"] T --> L
Worked example
A marketplace exposes order history to 40,000 merchants with page and page_size. Two problems surfaced in one quarter. Merchants syncing into accounting tools saw duplicates: a sync of 600 orders at 50 per page takes 12 requests over about 8 seconds, and a busy merchant's 3 new orders a minute push rows down so a page repeats the previous page's last row. That cost about 70 support tickets a month. And exports were slow: for a merchant with 2.4 million orders, page 40,000 took 1.9 s and a full export over 11 hours of database time.
The HLD change needed sign-off from the API, mobile and partner teams: replace page with an opaque cursor, document ordering and stability, keep page for six months with a deprecation header. The LLD change was one team's work: an OrderFeed with keyset logic, a versioned cursor codec, a composite index built concurrently, and a contract suite that inserts and deletes between fetches. Deep pages dropped from 1.9 s to 4 ms, the export took 9 minutes, and duplicate tickets stopped once partners migrated. The HLD meeting never discussed bisect; the LLD review never discussed the deprecation window.
Trade-offs
| Concern | HLD | LLD | Who should own it |
|---|---|---|---|
| Unit of design | Services, datastores, queues | Classes, interfaces, functions | HLD: architects and tech leads; LLD: the owning team |
| Failure model | Timeouts, partial failure, retries, outages | Invalid input, broken invariants, races | Both, at their own level |
| Change cost | Cross-team, migrations, deprecation windows | One team, one deploy | Whoever pays for the change |
| Main artefacts | Component and data flow diagrams, capacity math, API specs | Class, sequence and state diagrams, method contracts | The level's audience |
| Testing | Load tests, chaos, contract tests across services | Unit tests, property tests, contract tests per implementation | Contract tests are shared |
| Typical mistake | Naming classes, skipping failure modes | Silently changing an observable behaviour | Review at the boundary |
Pitfalls in production
- An LLD changes observable behaviour (sort order, an error message clients parse, a default page size) without anyone noticing it was part of the contract. Fix: list observable behaviour in the HLD and cover each item with a contract test.
- An HLD that fixes class names inside services, freezing choices and going stale in a month. Fix: stop the HLD at the service boundary and its API.
- Offset pagination behind a promise of stable paging. Fix: keyset cursors with a total order, as in the Practice.
- Treating a remote call like a local one in the LLD, for example calling a pricing service inside a loop over 500 cart lines. Fix: make the boundary visible in the interface (batch methods, explicit timeouts, a
Resulttype), so the cost is obvious at the call site. - No owner for an invariant, so two services each assume the other enforces "one active subscription per user". Fix: the HLD names the owner; that service's LLD enforces it.
Leadership lens
- Ask every design document which level it is at, and send detail that belongs at the other level to its own document. Mixed documents get reviewed by the wrong people.
- Make boundary contracts executable: a contract suite in CI is cheaper than a review that hunts the same bugs by reading.
- Budget HLD changes as cross-team projects with deprecation windows, and LLD changes as normal team work. Misclassifying one as the other is how projects slip by a quarter.
- When delegating to coding agents, put HLD promises into code they will read: interface docs, types and tests. An agent that cannot see the contract will optimise for the local unit test.
Check yourself
An engineer proposes switching the orders service from a Postgres table to DynamoDB "as an internal LLD change". When is that true, and when is it not?
Show answer
Only if no other service reads the table and the API's observable behaviour (ordering, consistency, latency) is unchanged. Otherwise it is an HLD change that needs the affected teams.
Why does offset pagination break the "every order exactly once" promise while keyset pagination keeps it?
Show answer
An offset counts positions, and inserts or deletes before the position shift which row sits there. A keyset cursor names the last key seen, and "keys strictly less than this one" does not move when other rows are added or removed.
In an LLD interview you are asked to design a URL shortener. How do you keep the answer at the right level?
Show answer
Treat storage and ID generation as interfaces and spend the time on classes, invariants (unique codes, expiry) and working flows. Mention sharding only to say which interface hides it.
What makes a cursor opaque, and why does that matter for the LLD?
Show answer
The contract says clients must not parse or build it, so the server may change its encoding. Versioning and optionally signing it gives the LLD freedom to evolve without an HLD change.
Watch
- Pagination in MySQL: offset vs. cursor (PlanetScale): why deep offsets get slow and how a keyset cursor fixes it, with query plans.
Free resources
- Use the Index, Luke: Paging Through Results: the definitive explanation of keyset pagination and why offsets do not scale.
- Google API Improvement Proposal 158: Pagination: how Google fixes the pagination contract at the API level (opaque page tokens, page size limits).
- Latency Numbers Every Programmer Should Know (Colin Scott): the cost gap between a method call and a network round trip.
- Consumer-Driven Contracts (Ian Robinson, martinfowler.com): how teams make boundary contracts executable.
- The C4 model for visualising software architecture: a vocabulary for drawing systems at different zoom levels, from context down to code.