Course Content
Mobile System Design Interview
11 sections · 23 lessons
Pagination Library: requirements and the public API
Prompt: "Design a pagination library."
This case study looks different from the six around it, and the difference is the lesson. Every other case study designs an app, where the user is a person and the deliverable is a screen. Here the user is another engineer and the deliverable is an API surface — a set of types and functions that will be called by code you will never see, in apps you cannot change, for years after you publish it.
That inversion changes what "good" means. In an app, an internal design you regret can be rewritten next sprint. In a library, a public method you regret is permanent, because removing it breaks every consumer. This lesson covers the questions, the requirements that follow from that inversion, and the public API itself; the next covers what sits behind it.
Why interviewers like this question
It separates two kinds of engineer cleanly. One reaches for the same answer as the news feed — cursors, prefetch thresholds, a repository — and produces a competent internal design. The other asks who is calling this and what happens when they need something the API does not offer. The second is what a senior engineer does, and it is difficult to fake.
The questions worth asking
"Who consumes this — one team, the whole company, or the public?" The blast radius of a breaking change scales with the answer, and it decides how conservative the API must be. An internal library used by four screens can evolve. A published one cannot.
"Which pagination styles must it support?" Cursor certainly. Offset probably, because some existing endpoint will use it. Ask about key-based generally — the honest design makes the key a type parameter so it supports all three without knowing which it is.
"Does it own caching, or compose with an existing cache?" A pivotal question. A library that insists on owning storage cannot be used by an app that already has a database, and that is most apps. Cache and invalidation argues for composing.
"Does it own the list rendering, or only the data?" Take data only. A library that supplies views forces its own presentation framework onto the consumer and dies the moment the platform's UI conventions shift.
"Which platforms and minimum versions?" The minimum version bounds which language and concurrency features you can use in a public signature — and a public signature cannot be changed later.
The scope to state back
In scope: fetching pages in order, exposing them as an observable list, tracking load state, deduplicating concurrent loads, cancellation, retry, and refresh.
Out of scope: rendering, networking (the consumer supplies the fetch), and any opinion about the consumer's architecture.
Requirements for a library, not an app
An app is judged by what the user sees. A library is judged by what a developer experiences while integrating it — and by what happens two years later when it must change.
The five requirements that are not "it works"
1. A small API surface. Every public type, method, and parameter is a promise you have to keep. Fifteen public symbols can be understood in an afternoon and evolved carefully. A hundred cannot be understood at all, and every one of them constrains you forever.
2. Testability, on both sides. Your own tests, and — more important — the consumer's. A library whose behaviour can only be exercised against a live network makes every consuming app's test suite slow and flaky. Error handling, testing, and follow-ups covers what you must expose to prevent that.
3. No forced architecture. The library must not require a particular presentation pattern, dependency injection framework, database, or threading model. Every requirement of that kind halves your potential adopters, and the ones you exclude are usually the large codebases you most wanted.
4. Backward compatibility. Once published, existing signatures keep working. New behaviour arrives as new optional parameters with defaults, or new types alongside old ones. A library that breaks its consumers on a minor version stops being adopted.
5. Good defaults, with escape hatches. The common case should need three lines. The unusual case should be possible without forking the library. Those two goals conflict, and resolving the conflict is the design.
Naming these unprompted is the whole differentiator
Say them out loud early:
"This is a library rather than an app, so alongside correctness I'm optimising for a small public surface, testability for the consumer, not forcing an architecture, and backward compatibility once it ships. Those change the design in ways that would look like over-engineering in an app."
That sentence is the strongest thing available in this question, because it reframes every subsequent decision. When you later choose an interface over a concrete class, or a data class over a set of parameters, you are not being fussy — you are honouring a requirement you stated.
The trade-off table, honestly
| Decision | Cheaper now | Better for a library |
|---|---|---|
| Fetch function | Concrete class the consumer extends | Interface the consumer implements |
| Configuration | Six constructor parameters | One config object with defaults |
| Errors | Throw | Return a result type the consumer inspects |
| Storage | Owns a database | Composes with the consumer's |
| Concurrency | Library picks the threads | Consumer supplies the context |
The right-hand column costs more to build and is what makes the library survive its second year.
The public API
The public API is the deliverable. Design it before anything internal, because the internals exist to serve it and not the reverse.
What the consumer provides, and what they get back
Two directions, and keeping them separate is what keeps the surface small.
In: a way to fetch one page, a page size, and a key type. Out: a stream of pages, and a stream of load state.
// --- what the consumer implements -------------------------------interface PageSource<Key, Item> { initialKey(): Key? load(key: Key?, size: Int): LoadResult<Key, Item>}sealed LoadResult<Key, Item> = Page(items: List<Item>, prevKey: Key?, nextKey: Key?) | Failure(error: Error)// --- what the library gives back --------------------------------class Pager<Key, Item>(config: PagerConfig, source: PageSource<Key, Item>) { val pages: Stream<PagedList<Item>> val loadState: Stream<LoadStates> fun refresh() fun retry()}data PagerConfig( pageSize: Int, prefetchDistance: Int = pageSize, initialLoadSize: Int = pageSize * 3, maxCachedPages: Int? = null // null = keep everything)Eight public symbols. Every one earns its place, and there is nothing in there about views, databases, or threads.
The common case in three lines
val pager = Pager(PagerConfig(pageSize = 20)) { key, size -> val r = api.feed(cursor = key, limit = size) Page(items = r.items, prevKey = null, nextKey = r.nextCursor)}observe(pager.pages) { list -> adapter.submit(list) }observe(pager.loadState) { s -> footer.render(s.append) }That is the test of the whole design. If the ordinary case does not fit in a few lines, the API is wrong regardless of how capable it is.
The decisions inside those eight symbols
Key is a type parameter. Making it generic is what supports cursor, offset, and page-number pagination without three code paths. A cursor consumer uses String; an offset consumer uses Int; a time-window consumer uses a date. The library never inspects the key — it stores what it was given and hands it back.
prevKey and nextKey are nullable, and null means end. No separate hasMore flag, because two sources of truth for the same fact eventually disagree. Null nextKey is the end of the list, explicitly.
Failures are returned, not thrown. A returned Failure forces the consumer to acknowledge the case in the type system, and lets the library treat "this page failed" as a state rather than as an exception unwinding through a coroutine or a completion handler.
Config is one object with defaults. Adding maxCachedPages later is a new field with a default and breaks nobody. Adding a fifth constructor parameter is a source-breaking change for anyone using positional arguments.
Offline: the API says nothing about networks, so an offline-capable consumer supplies a PageSource backed by their local database and the library works unchanged. That neutrality is deliberate — Cache and invalidation shows the pattern.