Online Book Reader LLD: The Bookmark Is the Whole Design
A low-level design walkthrough of an online book reader system: a catalog of shared books, per-user sessions, page navigation that clamps at the covers, and the user-book bookmark as the hidden noun.
"Design an online book reader." The Microsoft-flavored classic looks almost too gentle for Phase 3 — a catalog, some pages, next and previous buttons. Which is exactly why it's asked: with no algorithm to hide behind, the interviewer gets a clean read on whether you can find state that's been mislaid. Because the whole question turns on one observation: when you reopen a book on page 218, whose fact is "218"?
Not the book's — ten thousand people are reading that book at ten thousand different pages. Not the user's — you're mid-way through three books right now. It belongs to the pair, and a pair with its own data and lifetime is this queue's oldest friend: the hidden noun. Spot it in the first five minutes and the rest of the design is a quiet afternoon.
Let's start nowhere near a computer
A neighbourhood library keeps one shelf copy of each novel — and a box of plain paper bookmarks at the desk. When you return mid-book, the librarian doesn't dog-ear the shared copy (the next reader doesn't care where you stopped); she writes your name and the page on a slip and files it under your card. The book stays pristine and shared; the slip is personal.
One shared, immutable thing; many small per-person facts pointing into it. The moment you try to store the page on the book — the digital dog-ear — you've given every reader the same bookmark, and the design is already lost.
Where the slip-not-the-dog-ear rule runs
- Kindle, video apps, podcast players — "continue watching" is a (user, content) → position table; the content file is never touched.
- Course platforms — lesson progress per student over shared lessons; same slip, same box.
- Every multi-tenant system — shared resources plus per-user state is half of SaaS; this toy question is the smallest honest version of it.
Step 1 — Functional requirements (sentences first)
What the reader must do, as plain sentences — the functional requirements.
- The library holds books; each book has ordered pages.
- A user can search the catalog and open a book.
- An open book shows one page; the reader can go to the next or previous page.
- Navigation stops at the covers — no page −1, no page past the end.
- Closing and reopening a book resumes where that user stopped, in that book.
That last sentence is the whole question wearing a quiet disguise. "Resume where that user stopped, in that book" is not one fact — it's a fact about a pair, and a fact that has to survive the user walking away with no warning. Get the owner of "page 218" wrong and every other line above breaks in a way no test for "next" and "previous" will catch.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for a book reader they decide where each fact lives:
- Correctness. A reopened book resumes the same spot, and that spot belongs to the right (user, book) pair — never one reader's page leaking onto another's, never a position that drifts when the same book is reopened.
- Performance. Open and turn are point operations — a keyed lookup, not a scan of every reader or every page; search is a catalog walk, not a re-read of book contents.
- Extensibility. New per-(user, book) facts — highlights, notes, recently-read — must join the existing home without new structure; the composite key already anticipates them.
- Separation of concerns. The shared, immutable content (Book, Library) is one responsibility; the personal, mutable progress (the slip) is another. Mixing them is the canonical wrong answer.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | the slip is keyed by the (user, book) pair and updated on every turn, so resume maps back exactly — Steps 3, 4 |
| Performance | open/turn are Map lookups by composite key; no scan of users or pages — Step 4 |
| Extensibility | new per-pair facts join the slip's home unchanged; the key already fits them — Step 5 |
| Separation of concerns | immutable Book/Library carry no progress; the mutable slip lives only in ReaderService — Steps 3, 4 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns: one shared, one personal, one hidden
- Book — id, title, pages. Immutable and shared: a record. The pristine shelf copy.
- Library — the catalog: add and search. (Search here is the honest, simple kind — a title scan; the trie article is where that upgrades.)
- Bookmark — the hidden noun: (user, book) → page index, owned by neither. It lives in the reader service's map, keyed by the pair.
- ReaderService — the librarian: opens books, turns pages, files slips.
The cast deliberately omits a User class (Tic-Tac-Toe's restraint): we track nothing about users except their id-keyed slips. Invent nouns when they earn their state.
Which structure — and why. The bookmark is a Map keyed by the composite (user, book), not a page field on Book and not a Session object — and that's the load-bearing choice, not a default. A map keyed by the pair gives open and turn an O(1) point lookup (the performance NFR) and structurally guarantees one reader's page can't touch another's (the correctness NFR), because the key is the owner. The Book is an immutable record — pages fixed at construction — so the shared content can never accumulate per-user state (the separation of concerns NFR); putting the page on the book would hand every reader the same dog-ear. And the slip's value is a place that more facts can join later — a richer object keyed the same way — which is the extensibility NFR sitting dormant in the key you already chose.
Step 4 — The librarian's three verbs
/** Opens a book for a user — resuming from their slip, or page 0. */
public synchronized String open(String userId, String bookId) {
Book book = library.byId(bookId);
int page = bookmarks.getOrDefault(key(userId, bookId), 0);
return book.pages().get(page); // the resume IS the feature
}
/** Turns the page — clamped at the covers, never an exception. */
public synchronized String turnPage(String userId, String bookId, int delta) {
Book book = library.byId(bookId);
int current = bookmarks.getOrDefault(key(userId, bookId), 0);
int next = Math.max(0, Math.min(book.pages().size() - 1, current + delta));
bookmarks.put(key(userId, bookId), next); // the slip updates on every turn
return book.pages().get(next);
}Two graded choices in fourteen lines. Clamping, not throwing: pressing "next" on the last page is a reader resting their thumb, not a programming error — the Tetris rule that illegal user gestures are non-events, while illegal API calls (unknown book id) still throw. And the slip updates on every turn, not on some imagined "close" event — readers don't close books, they lock their phones; durable-on-every-action is the only model that survives reality.
The key(userId, bookId) composite is doing quiet architectural work: it's
the primary key of the bookmarks table this would become in the HLD
version — a (user_id, book_id, page) row
with a unique constraint. Saying "this map is that table" out loud connects
the zoom levels, and interviewers notice.
Step 5 — What this design refuses to invent
Worth narrating, because restraint is the grade here: no Session object (the bookmark map is the session state, durable instead of ephemeral); no page cache (pages are already in memory; in the real system that's a CDN concern, not an object); no reading-progress percentage, fonts, or highlights — each is one more per-(user, book) fact that joins the same slip, which is precisely why the slip was the right home.
The edges worth naming out loud, because they're where correctness is won or lost: opening a book not in the catalog is an illegal API call — byId throws, it isn't a reader gesture; next on the last page or previous on the first is a reader resting a thumb — clamped, never thrown; an empty search term matches by contains and harmlessly returns the whole catalog rather than erroring. And the one that decides the whole design — a position that should survive a font or layout change — is why a serious reader anchors the slip to content (a chapter id plus character offset), not a raw page number that shifts the moment the layout reflows. Our toy stores a page index because pages here are fixed strings; the instant fonts can repaginate, the page number is the dog-ear all over again, and the slip must point at the text, not the pixel.
Step 6 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
| slip keyed by (user, book) pair | a page field on Book | one reader's position can't leak onto another's — the owner is the key | correctness |
| update the slip on every turn | save on a "close" event | survives a locked phone; readers never explicitly close anything | correctness |
| clamp navigation at the covers | throw past the last page | a thumb on "next" is a non-event, not a crash; only bad API calls throw | correctness |
| bookmark map keyed for O(1) lookup | scan readers/pages to resume | open and turn stay point operations as the catalog grows | performance |
| anchor position to content | store a raw page number | the locator survives a font/layout reflow; pixels move, the text doesn't | correctness |
immutable Book, slip in service | progress stored on the book | shared content and personal progress stay separate responsibilities | separation of concerns |
The complete implementation
package dev.fiveyear.reader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public record Book(String id, String title, List<String> pages) {
public Book {
if (pages.isEmpty()) {
throw new IllegalArgumentException("a book needs at least one page");
}
pages = List.copyOf(pages);
}
}
public final class Library {
private final Map<String, Book> byId = new HashMap<>();
public void add(Book book) {
byId.put(book.id(), book);
}
public Book byId(String bookId) {
Book book = byId.get(bookId);
if (book == null) {
throw new IllegalArgumentException("no book " + bookId);
}
return book;
}
public List<String> search(String titlePart) {
List<String> hits = new ArrayList<>();
for (Book book : byId.values()) {
if (book.title().toLowerCase().contains(titlePart.toLowerCase())) {
hits.add(book.id());
}
}
return hits;
}
}package dev.fiveyear.reader;
import java.util.HashMap;
import java.util.Map;
/** The librarian: opens books, turns pages, files the slips. */
public final class ReaderService {
private final Library library;
private final Map<String, Integer> bookmarks = new HashMap<>(); // (user|book) → page
public ReaderService(Library library) {
this.library = library;
}
public synchronized String open(String userId, String bookId) {
Book book = library.byId(bookId);
int page = bookmarks.getOrDefault(key(userId, bookId), 0);
return book.pages().get(page);
}
public synchronized String turnPage(String userId, String bookId, int delta) {
Book book = library.byId(bookId);
int current = bookmarks.getOrDefault(key(userId, bookId), 0);
int next = Math.max(0, Math.min(book.pages().size() - 1, current + delta));
bookmarks.put(key(userId, bookId), next);
return book.pages().get(next);
}
public synchronized int pageOf(String userId, String bookId) {
return bookmarks.getOrDefault(key(userId, bookId), 0);
}
private static String key(String userId, String bookId) {
return userId + "|" + bookId;
}
}Two readers, one shelf copy:
Library library = new Library();
library.add(new Book("b1", "Deep Work", List.of("p0", "p1", "p2", "p3")));
ReaderService reader = new ReaderService(library);
reader.open("asha", "b1"); // "p0" — a fresh slip
reader.turnPage("asha", "b1", +1); // "p1"
reader.turnPage("asha", "b1", +1); // "p2"
reader.turnPage("asha", "b1", -1); // "p1"
reader.turnPage("asha", "b1", -9); // "p0" — clamped at the front cover
reader.open("dev", "b1"); // "p0" — HIS slip, untouched by hers
reader.turnPage("dev", "b1", +3); // "p3" — and clamped there forever after
reader.open("asha", "b1"); // "p0" — her slip remembers the clamp
library.search("deep"); // [b1]The interview corner
Clarify before you code: One active book per user (CTCI's version) or many in parallel (ours — the bookmark map gives it for free)? Is search in scope, and how fancy? Do we model membership/checkout limits, or pure reading?
The follow-up ladder:
- "Add highlights and notes." More per-(user, book) facts — they join the bookmark's home as a richer value object, and the key you chose proves itself: zero new structure.
- "Sync across devices." The slip map becomes a server-side table; the interesting edge is conflict — phone offline at page 50, tablet reads to 80. Last-write-wins loses reading; max(page) wins is the domain-aware merge. Naming a domain merge rule beats naming a vector clock.
- "Millions of users — where do bookmarks live?" A keyed table partitioned by user id; reads are point lookups by the composite key. This toy's map IS that schema — say it and the HLD bridge is crossed.
- "Limit concurrent readers per book (publisher licensing)." Suddenly the inventory reservation walks in: N license seats, check out and release. Watch your designs start composing — that's the queue's whole bet.
- "Recommendations / recently-read." The bookmark map with timestamps is already the raw feed: a per-user list sorted by last touch. Features fall out of state that was put in the right place.
Mistakes that fail the round: page state on the Book (the shared dog-ear); a Session that loses everything on close; throwing on next-at-last-page; inventing User and BookCopy classes that carry no state this scope needs.
Where to go from here
Pocket version: books are shared and immutable, the bookmark is a (user, book) fact in its own home, turns clamp at the covers, and the slip updates on every action because nobody ever "closes" anything.
- Add the license-seat limit from the ladder — it's a twenty-minute exercise with the inventory article open.
- Add
recentlyRead(userId)— timestamp the slips and return them sorted; one map upgrade, one new feature. - Next in the queue: the Android unlock pattern — from the quietest design in Phase 3 to its most geometric: nine dots, one skip table, and a backtracking count.
The librarian never dog-ears the shelf copy. Ten thousand readers, ten thousand slips, one pristine book — and now you know which line of that sentence is the schema.