Android System Design Interview Questions
Design a networking library.
Tier: CommonDifficulty: Hard
This is asking you to justify the shape of something like Retrofit plus OkHttp, not necessarily rebuild it from raw sockets. The useful way to answer is to name the layers and why each one exists, since that's what actually gets tested in a design round.
What I'd clarify first
- Does this need to support both REST-style request/response and something long-lived like WebSocket, or is it request/response only.
- Is auth token refresh and retry logic something the library owns, or something callers configure per app.
- Does it need built-in offline caching, or is that the caller's responsibility on top of it.
Layered architecture
- Transport layer, owns the actual socket, TLS, connection pooling, and retry on connection failure. This is the OkHttp-shaped layer, and it should be swappable in principle, even if in practice there's one obvious default.
- Interceptor chain, a pipeline every request and response passes through, where cross-cutting concerns live, auth headers, logging, gzip, cache headers, without any of them needing to know about each other. Each interceptor calls the next and can inspect or rewrite what comes back.
- Request mapping layer, the Retrofit-shaped layer, turning a declared interface or a builder call into an actual request, and turning the raw response back into a typed object through a pluggable converter, JSON today, something else tomorrow, without changing call sites.
- Call adapter layer, adapting the underlying async callback into whatever the caller's concurrency model is, a suspend function for coroutines, an
Observablefor RxJava, so the library isn't locked to one concurrency style.
How a request flows
A caller declares what it wants, either an annotated interface method or a builder. The mapping layer turns that into a concrete request and hands it to the interceptor chain, each interceptor gets a chance to inspect or modify it, an auth interceptor adds a token, a logging interceptor records it, before it reaches the transport layer, which actually opens the connection and sends it. The response flows back through the same chain in reverse, a token-refresh interceptor can catch a 401 here and retry transparently, before the mapping layer converts the body into the caller's declared type and the call adapter delivers it back in whatever form the caller expects.
Laid out as those layers, with the chain written in full. RealChain is worth reading closely, the chain handed to an interceptor is a new chain positioned one step further along, and that single detail is what gives you short circuiting, retry and a response path for free.
Java
com.androidinterview.networking.client.HttpClient.java
package com.androidinterview.networking.client;
import com.androidinterview.networking.http.Request;
import com.androidinterview.networking.http.Response;
import com.androidinterview.networking.interceptor.Interceptor;
import com.androidinterview.networking.interceptor.RealChain;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public final class HttpClient {
// The transport is the only part that touches a socket, connection pool
// and TLS. Making it the tail of the chain rather than a special case
// below it is what lets a cache or a test double answer a request without
// one, and it is what makes the transport swappable at all.
public interface Transport {
Response send(Request request) throws IOException;
}
// A call is just a thing that can be executed. It exists so the adapter
// below has something to wrap.
public interface Call {
Response execute() throws IOException;
}
// The seam that keeps this library out of an argument about concurrency.
// The same call becomes a suspending function, an Observable or a blocking
// call depending only on which adapter the app installs, and nothing below
// this line changes. A body converter, JSON today and something else
// tomorrow, is the same idea one layer over.
public interface CallAdapter<T> {
T adapt(Call call);
}
private final List<Interceptor> chain;
public HttpClient(List<Interceptor> interceptors, Transport transport) {
List<Interceptor> full = new ArrayList<>(interceptors);
full.add(tail -> transport.send(tail.request()));
// Order is the cost of this design and it is not obvious from a call
// site. An interceptor added after the one that sets the auth header
// sees the request carrying it, one added before sees it without.
this.chain = List.copyOf(full);
}
public Response execute(Request request) throws IOException {
return new RealChain(chain, request).proceed(request);
}
public <T> T newCall(Request request, CallAdapter<T> adapter) {
return adapter.adapt(() -> execute(request));
}
}
com.androidinterview.networking.http.Request.java
package com.androidinterview.networking.http;
import java.util.LinkedHashMap;
import java.util.Map;
public record Request(String url, String method, Map<String, String> headers, byte[] body) {
public Request {
headers = Map.copyOf(headers);
}
public static Request get(String url) {
return new Request(url, "GET", Map.of(), new byte[0]);
}
// Requests are immutable, so an interceptor rewrites by copying. That is
// what makes a retry safe, the interceptor that retries still holds the
// original request rather than one three other interceptors have edited
// underneath it.
public Request withHeader(String name, String value) {
Map<String, String> merged = new LinkedHashMap<>(headers);
merged.put(name, value);
return new Request(url, method, merged, body);
}
}
com.androidinterview.networking.http.Response.java
package com.androidinterview.networking.http;
import java.util.Map;
public record Response(int code, Map<String, String> headers, byte[] body) {
public Response {
headers = Map.copyOf(headers);
}
}
com.androidinterview.networking.interceptor.Interceptor.java
package com.androidinterview.networking.interceptor;
import com.androidinterview.networking.http.Request;
import com.androidinterview.networking.http.Response;
import java.io.IOException;
// One interface, and every cross cutting concern in the library is an
// implementation of it. Auth, logging, gzip, cache headers and retry all sit
// on this seam and none of them knows the others exist.
public interface Interceptor {
Response intercept(Chain chain) throws IOException;
interface Chain {
Request request();
Response proceed(Request request) throws IOException;
}
}
com.androidinterview.networking.interceptor.RealChain.java
package com.androidinterview.networking.interceptor;
import com.androidinterview.networking.http.Request;
import com.androidinterview.networking.http.Response;
import java.io.IOException;
import java.util.List;
// The chain is this one method, and the trick is that the chain handed to an
// interceptor is a new chain positioned one step further along.
public final class RealChain implements Interceptor.Chain {
private final List<Interceptor> interceptors;
private final int index;
private final Request request;
public RealChain(List<Interceptor> interceptors, Request request) {
this(interceptors, 0, request);
}
private RealChain(List<Interceptor> interceptors, int index, Request request) {
this.interceptors = interceptors;
this.index = index;
this.request = request;
}
@Override
public Request request() {
return request;
}
// Calling proceed runs everything below this interceptor and returns what
// came back, so an interceptor sees the request on the way down and the
// response on the way up without either half being a separate callback.
//
// An interceptor that never calls proceed short circuits the request,
// which is how a cache interceptor answers without a socket. One that
// calls it twice retries, and the retry reruns every interceptor below it.
@Override
public Response proceed(Request request) throws IOException {
if (index >= interceptors.size()) {
throw new IllegalStateException("The chain ran out before anything produced a response");
}
return interceptors.get(index).intercept(new RealChain(interceptors, index + 1, request));
}
}
com.androidinterview.networking.interceptor.TokenRefreshInterceptor.java
package com.androidinterview.networking.interceptor;
import com.androidinterview.networking.http.Request;
import com.androidinterview.networking.http.Response;
import java.io.IOException;
// The interceptor that justifies the whole chain. A caller asks for a resource
// and never learns that its token expired halfway through.
public final class TokenRefreshInterceptor implements Interceptor {
public interface TokenStore {
String token();
String refreshed();
}
private static final int UNAUTHORIZED = 401;
private final TokenStore tokens;
public TokenRefreshInterceptor(TokenStore tokens) {
this.tokens = tokens;
}
@Override
public Response intercept(Chain chain) throws IOException {
Response response = chain.proceed(authorized(chain.request(), tokens.token()));
if (response.code() != UNAUTHORIZED) {
return response;
}
// The second proceed is the retry, and it reruns everything below this
// interceptor, so gzip and the transport see the new request too. Note
// what this says about ordering. An interceptor installed after this
// one gets called twice, one installed before it gets called once.
return chain.proceed(authorized(chain.request(), tokens.refreshed()));
}
private Request authorized(Request request, String token) {
return request.withHeader("Authorization", "Bearer " + token);
}
}
Kotlin
com.androidinterview.networking.client.HttpClient.kt
package com.androidinterview.networking.client
import com.androidinterview.networking.http.Request
import com.androidinterview.networking.http.Response
import com.androidinterview.networking.interceptor.Interceptor
import com.androidinterview.networking.interceptor.RealChain
// The transport is the only part that touches a socket, connection pool and
// TLS. Making it the tail of the chain rather than a special case below it is
// what lets a cache or a test double answer a request without one, and it is
// what makes the transport swappable at all.
fun interface Transport {
fun send(request: Request): Response
}
// The seam that keeps this library out of an argument about concurrency. The
// same call becomes a suspending function, a Flow or a blocking call depending
// only on which adapter the app installs, and nothing below this line changes.
// A body converter, JSON today and something else tomorrow, is the same idea
// one layer over.
fun interface CallAdapter<T> {
fun adapt(call: () -> Response): T
}
class HttpClient(interceptors: List<Interceptor>, transport: Transport) {
// Order is the cost of this design and it is not obvious from a call site.
// An interceptor added after the one that sets the auth header sees the
// request carrying it, one added before sees it without.
private val chain = interceptors + Interceptor { transport.send(it.request) }
fun execute(request: Request): Response = RealChain(chain, request).proceed(request)
fun <T> newCall(request: Request, adapter: CallAdapter<T>): T = adapter.adapt { execute(request) }
}
com.androidinterview.networking.http.Http.kt
package com.androidinterview.networking.http
// Requests are immutable, so an interceptor rewrites by copying, which is what
// copy gives us for nothing. That is what makes a retry safe, the interceptor
// that retries still holds the original request rather than one three other
// interceptors have edited underneath it.
data class Request(
val url: String,
val method: String = "GET",
val headers: Map<String, String> = emptyMap(),
val body: ByteArray = ByteArray(0),
) {
fun withHeader(name: String, value: String) = copy(headers = headers + (name to value))
}
data class Response(val code: Int, val headers: Map<String, String> = emptyMap(), val body: ByteArray = ByteArray(0))
com.androidinterview.networking.interceptor.Interceptor.kt
package com.androidinterview.networking.interceptor
import com.androidinterview.networking.http.Request
import com.androidinterview.networking.http.Response
// One interface, and every cross cutting concern in the library is an
// implementation of it. Auth, logging, gzip, cache headers and retry all sit
// on this seam and none of them knows the others exist.
fun interface Interceptor {
fun intercept(chain: Chain): Response
interface Chain {
val request: Request
fun proceed(request: Request): Response
}
}
// The chain is one method, and the trick is that the chain handed to an
// interceptor is a new chain positioned one step further along.
//
// Calling proceed runs everything below this interceptor and returns what came
// back, so an interceptor sees the request on the way down and the response on
// the way up without either half being a separate callback. One that never
// calls proceed short circuits the request, which is how a cache interceptor
// answers without a socket. One that calls it twice retries.
class RealChain(
private val interceptors: List<Interceptor>,
override val request: Request,
private val index: Int = 0,
) : Interceptor.Chain {
override fun proceed(request: Request): Response {
check(index < interceptors.size) { "The chain ran out before anything produced a response" }
return interceptors[index].intercept(RealChain(interceptors, request, index + 1))
}
}
com.androidinterview.networking.interceptor.TokenRefreshInterceptor.kt
package com.androidinterview.networking.interceptor
import com.androidinterview.networking.http.Request
import com.androidinterview.networking.http.Response
private const val UNAUTHORIZED = 401
// The interceptor that justifies the whole chain. A caller asks for a resource
// and never learns that its token expired halfway through.
class TokenRefreshInterceptor(
private val token: () -> String,
private val refreshed: () -> String,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request.authorized(token()))
if (response.code != UNAUTHORIZED) return response
// The second proceed is the retry, and it reruns everything below this
// interceptor, so gzip and the transport see the new request too. Note
// what that says about ordering. An interceptor installed after this
// one gets called twice, one installed before it gets called once.
return chain.proceed(chain.request.authorized(refreshed()))
}
private fun Request.authorized(value: String) = withHeader("Authorization", "Bearer $value")
}
Tradeoffs I'd call out
- Interceptor chain vs a single configurable client. Interceptors let you compose behavior, auth plus logging plus retry, without any one piece knowing about the others, which is what makes the design extensible. The cost is ordering matters and isn't always obvious, an interceptor added in the wrong position can see the wrong version of the request, before or after another interceptor's changes.
- Built-in retry and token refresh vs leaving it to callers. Owning retry and refresh inside the library means every app using it gets sane defaults for free, but it also means the library has to expose enough configuration to cover different auth schemes, or it becomes the wrong abstraction for an app whose auth doesn't fit the assumed shape.
- One converter format vs pluggable converters. Hardcoding JSON is simpler to build and simpler to reason about, but a library meant to be reused across projects needs converters to be swappable, since not every backend speaks JSON, and even among the ones that do, teams disagree on Gson versus Moshi versus kotlinx.serialization.
What breaks at scale, offline, and on a poor connection
At scale, a fixed connection pool per host and reasonable timeouts matter more than they seem to in development, an unbounded number of simultaneous requests to the same host either gets your app throttled by the server or exhausts the device's own connection limits. Offline, this library shouldn't be the thing deciding what "offline" means for the app, that's a caching or sync layer built on top, but it should surface connectivity failures as a distinct, catchable error type rather than lumping them in with a generic exception, so calling code can actually tell "no network" apart from "server returned garbage." On a poor connection, sane default timeouts and an interceptor-based retry with backoff are what keep a flaky connection from either hanging a request forever or hammering a struggling server with immediate retries.
Watch