Mobile System Design Interview

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.

Scoping a library rather than an appLibrary scopeWhich platform?Does it own storage?Forward only or both?How are errors shown?What stays stable?
Interviewers like this prompt because API design and versioning have nowhere to hide behind a screenshot.

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.

How an app is judged against how a library is judgedAn app is judged by• What the user sees on screen• Latency and crash rate• Whether the feature works todayA library is judged by• The integration experience• Testability without a network• Whether it can change in two years
Naming versioning and testability unprompted is the whole differentiator on this question.

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

DecisionCheaper nowBetter for a library
Fetch functionConcrete class the consumer extendsInterface the consumer implements
ConfigurationSix constructor parametersOne config object with defaults
ErrorsThrowReturn a result type the consumer inspects
StorageOwns a databaseComposes with the consumer's
ConcurrencyLibrary picks the threadsConsumer 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.

Text
// --- 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

Text
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.

What a pagination library exposes, and what it hidesPUBLIC APIPagingSourceload(key) → page, prevKey, nextKeyPagingConfigpageSize, prefetchDistance, initialLoadPagera stream of paged dataLoadStateloading / error / idle, per directionretry()re-runs only the failed loadrefresh()invalidates and starts againHIDDEN BEHIND ITwhen to prefetch the next pagede-duplicating concurrent loads for the same keydropping pages that scrolled far off screenkeeping scroll position stable when a page is insertedretry with backoff, and which page failedcancelling in-flight loads on refreshA library question is an API-design question: the score comes from what you chose to leave out, not from the feature list.
Six public types, and every hard problem on the right-hand side is one the caller never has to name.

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.