Design the Networking Layer for a Large App
Requirements
You design the networking code that every feature team in a big app uses. Bad design here spreads everywhere: copy-pasted requests, random retries, token bugs, and tests that hit the real server.
Functional
- Typed requests: each endpoint knows its path, method, body and response type.
- JSON encoding and decoding.
- Add common headers: auth token, app version, language, request id.
- Refresh the access token on
401, once, even when 10 requests fail at the same time. - Retry safe requests on temporary errors, with backoff.
- Cancellation: leaving a screen cancels its requests.
- Different environments: dev, staging, production.
Non-functional
- Easy to use: one line per call for feature code.
- Testable: no real network in unit tests.
- Swift 6 concurrency-safe.
- Observable: logs and metrics, without leaking tokens or personal data.
- Small: built on
URLSession, no heavy dependencies.
Out of scope
- GraphQL, WebSockets, offline sync (other layers can build on this one).
Questions to ask the interviewer
- How many teams and modules use it? (Then it is its own Swift package.)
- REST only? What does the server's error JSON look like?
- What auth? OAuth 2 with access and refresh tokens?
- Minimum iOS version? (async/await needs iOS 15 for
URLSession.) - Do we need background uploads or downloads?
High-level architecture
Feature code (ViewModel / Repository)
|
| let user = try await client.send(GetUser(id: "42"))
v
+------------------+
| APIClient | Endpoint -> URLRequest, runs the chain, maps errors, decodes
+--------+---------+
|
v
+-----------------------------------------------------------------+
| Middleware chain (order matters) |
| [Headers] -> [Auth] -> [Retry] -> [Logging] -> transport |
+-------------------------------------------+---------------------+
|
+--------------------------+ +----------------------+
| | AuthManager |
v | (actor) tokens, |
+------------------+ | single refresh, |
| HTTPTransport | | Keychain |
| (URLSession) | +----------------------+
+------------------+| Part | Job |
|---|---|
Endpoint | Describes one API call and its response type. |
APIClient | Builds the request, runs middlewares, checks status, decodes. |
| Middleware | One small job each: headers, auth, retry, logging. |
HTTPTransport | Sends a URLRequest. URLSession in the app, a fake in tests. |
AuthManager | Holds tokens. Makes sure only one refresh runs at a time. |
Why a chain of middlewares? Each concern is small and tested alone. New needs (metrics, mock headers in debug) are a new middleware, not changes in a giant function.
Data model
enum HTTPMethod: String, Sendable {
case get = "GET", post = "POST", put = "PUT", patch = "PATCH", delete = "DELETE"
}
protocol Endpoint: Sendable {
associatedtype Response: Decodable & Sendable
var method: HTTPMethod { get }
var path: String { get }
var query: [URLQueryItem] { get }
var body: (any Encodable & Sendable)? { get }
var requiresAuth: Bool { get }
var isIdempotent: Bool { get } // safe to send twice?
}
extension Endpoint {
var query: [URLQueryItem] { [] }
var body: (any Encodable & Sendable)? { nil }
var requiresAuth: Bool { true }
var isIdempotent: Bool { method != .post && method != .patch }
}
struct EmptyResponse: Decodable, Sendable {}
// One endpoint = one small type.
struct GetUser: Endpoint {
typealias Response = User
let id: String
var method: HTTPMethod { .get }
var path: String { "/v1/users/\(id)" }
}
struct UpdateName: Endpoint {
typealias Response = User
let name: String
var method: HTTPMethod { .patch }
var path: String { "/v1/me" }
var body: (any Encodable & Sendable)? { ["name": name] }
}Errors are one clear enum. The UI can switch on it and show the right message.
enum APIError: Error, Sendable {
case offline
case timeout
case cancelled
case unauthorized // refresh failed: user must log in again
case server(status: Int, body: ServerErrorBody?)
case decoding(String) // a message, so the enum stays Sendable
case transport(URLError)
init(_ error: URLError) {
switch error.code {
case .notConnectedToInternet, .networkConnectionLost: self = .offline
case .timedOut: self = .timeout
case .cancelled: self = .cancelled
default: self = .transport(error)
}
}
}
struct ServerErrorBody: Decodable, Sendable {
let code: String // "email_taken"
let message: String
}API / interfaces
Server contract (agree on it with the backend team):
Authorization: Bearer <accessToken>
X-Request-ID: <uuid> same id in app logs and server logs
Accept-Language: en-US
Errors: { "code": "email_taken", "message": "This email is already used." }
POST /oauth/token grant_type=refresh_token&refresh_token=... -> new access + refresh tokenSwift interfaces
typealias Next = @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
struct RequestContext: Sendable {
let requiresAuth: Bool
let isIdempotent: Bool
}
protocol Middleware: Sendable {
func intercept(_ request: URLRequest, context: RequestContext,
next: Next) async throws -> (Data, HTTPURLResponse)
}
protocol HTTPTransport: Sendable {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
struct URLSessionTransport: HTTPTransport {
let session: URLSession // ONE shared session, not one per request
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }
return (data, http)
}
}The client puts it all together:
struct APIClient: Sendable {
let baseURL: URL
let transport: HTTPTransport
let middlewares: [any Middleware]
func send<E: Endpoint>(_ endpoint: E) async throws -> E.Response {
let request = try makeRequest(for: endpoint)
let context = RequestContext(requiresAuth: endpoint.requiresAuth,
isIdempotent: endpoint.isIdempotent)
// Build the chain from the inside out. The transport is the last step.
var next: Next = { [transport] in try await transport.send($0) }
for middleware in middlewares.reversed() {
let inner = next
next = { try await middleware.intercept($0, context: context, next: inner) }
}
let result: (Data, HTTPURLResponse)
do {
result = try await next(request)
} catch let error as URLError {
throw APIError(error)
}
return try decode(E.Response.self, from: result.0, response: result.1)
}
private func makeRequest<E: Endpoint>(for endpoint: E) throws -> URLRequest {
var components = URLComponents(url: baseURL.appending(path: endpoint.path),
resolvingAgainstBaseURL: false)
if !endpoint.query.isEmpty { components?.queryItems = endpoint.query }
guard let url = components?.url else { throw URLError(.badURL) }
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let body = endpoint.body {
request.httpBody = try JSONEncoder().encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
}Deep dives
1. Token refresh: only one at a time
The classic bug: the token expires. Ten requests get 401 at the same time. All ten call
"refresh". The server rotates the refresh token on the first call, so the other nine fail, and the
user is logged out.
The fix is a single-flight refresh: the first caller starts it, the others wait for the same task.
struct Tokens: Codable, Sendable {
let access: String
let refresh: String
let expiresAt: Date
}
protocol TokenRefreshing: Sendable {
/// Throws APIError.unauthorized when the server rejects the refresh token.
func refresh(using refreshToken: String) async throws -> Tokens
}
actor AuthManager {
private var tokens: Tokens?
private var refreshTask: Task<Tokens, Error>?
private let refresher: TokenRefreshing
private let onLogout: @Sendable () async -> Void
init(tokens: Tokens?, refresher: TokenRefreshing,
onLogout: @escaping @Sendable () async -> Void) {
self.tokens = tokens
self.refresher = refresher
self.onLogout = onLogout
}
func validAccessToken() async throws -> String {
if let refreshTask { return try await refreshTask.value.access } // join
guard let tokens else { throw APIError.unauthorized }
if tokens.expiresAt > Date.now.addingTimeInterval(60) { return tokens.access }
return try await refresh(after: tokens.access) // refresh early
}
func refresh(after failedToken: String) async throws -> String {
if let refreshTask { return try await refreshTask.value.access } // join
guard let current = tokens else { throw APIError.unauthorized }
if current.access != failedToken { return current.access } // already refreshed
let task = Task { try await refresher.refresh(using: current.refresh) }
refreshTask = task // set BEFORE the await, so other callers join it
defer { refreshTask = nil }
do {
let new = try await task.value
tokens = new // also save to the Keychain here
return new.access
} catch APIError.unauthorized {
// Only a rejected refresh token logs out. Offline or timeout errors
// are thrown as they are, and the tokens stay.
tokens = nil
await onLogout()
throw APIError.unauthorized
}
}
}Three things to say out loud:
- The actor protects
tokens, but actors are reentrant: other calls run duringawait. That is whyrefreshTaskis set before the firstawait. refresh(after: failedToken): a request that failed with an old token must not refresh again.- Log out only when the server rejects the refresh token, never because the phone was offline.
The auth middleware uses it, and retries once after a refresh:
struct AuthMiddleware: Middleware {
let auth: AuthManager
func intercept(_ request: URLRequest, context: RequestContext,
next: Next) async throws -> (Data, HTTPURLResponse) {
guard context.requiresAuth else { return try await next(request) }
let token = try await auth.validAccessToken()
var authed = request
authed.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await next(authed)
guard response.statusCode == 401 else { return (data, response) }
let newToken = try await auth.refresh(after: token)
authed.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
return try await next(authed) // only one retry: no endless loop
}
}2. Retries and backoff
Retry only when it is safe and useful:
- Safe: the request is idempotent (
GET,PUT,DELETE), or aPOSThas anIdempotency-Keyheader. - Useful: the error is temporary. Timeouts, a lost connection,
408,429,502,503,504. - Not useful:
400,404,422. Sending the same bad request again gives the same answer.
struct RetryMiddleware: Middleware {
var maxAttempts = 3
var sleep: @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
func intercept(_ request: URLRequest, context: RequestContext,
next: Next) async throws -> (Data, HTTPURLResponse) {
var attempt = 1
while true {
do {
let (data, response) = try await next(request)
let retryable = [408, 429, 502, 503, 504].contains(response.statusCode)
guard retryable, context.isIdempotent, attempt < maxAttempts else {
return (data, response)
}
let retryAfter = response.value(forHTTPHeaderField: "Retry-After")
try await sleep(delay(attempt: attempt, retryAfter: retryAfter))
} catch let error as URLError
where error.isTransient && context.isIdempotent && attempt < maxAttempts {
try await sleep(delay(attempt: attempt, retryAfter: nil))
}
attempt += 1
}
}
private func delay(attempt: Int, retryAfter: String?) -> Duration {
if let retryAfter, let seconds = Double(retryAfter) { return .seconds(min(seconds, 30)) }
let base = 0.5 * pow(2, Double(attempt - 1)) // 0.5 s, 1 s, 2 s
return .seconds(base + Double.random(in: 0...(base / 2))) // + jitter
}
}
extension URLError {
var isTransient: Bool {
let codes: [URLError.Code] = [.timedOut, .networkConnectionLost,
.cannotConnectToHost, .dnsLookupFailed]
return codes.contains(code)
}
}Details that matter:
- Jitter (a random extra wait) stops all phones from retrying at the same moment after an outage.
Task.sleepthrows when the task is cancelled. So leaving the screen also stops the retries.sleepis injected. Tests run instantly and check the delays..notConnectedToInternetis not retried here. Show "You are offline", or usewaitsForConnectivityfor background work.- Order:
Retryis insideAuth, so every retry already has the token, and a401goes back up toAuthto refresh.Loggingis innermost, so every real attempt is logged.
3. Decoding
Set up the decoder once, the same way for every call:
extension APIClient {
func decode<T: Decodable>(_ type: T.Type, from data: Data,
response: HTTPURLResponse) throws -> T {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom { decoder in
let text = try decoder.singleValueContainer().decode(String.self)
let withFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
if let date = try? Date(text, strategy: withFraction) { return date }
return try Date(text, strategy: Date.ISO8601FormatStyle())
}
switch response.statusCode {
case 200..<300:
let body = data.isEmpty ? Data("{}".utf8) : data // 204 -> EmptyResponse
do { return try decoder.decode(T.self, from: body) }
catch { throw APIError.decoding("\(T.self): \(error)") }
case 401:
throw APIError.unauthorized
default:
let serverError = try? decoder.decode(ServerErrorBody.self, from: data)
throw APIError.server(status: response.statusCode, body: serverError)
}
}
}Gotchas interviewers like:
- The built-in
.iso8601strategy fails on fractional seconds (2026-10-16T10:00:00.123Z). That is why the custom strategy tries both. - One bad item should not break a whole list. Decode lists "lossy": skip items that fail.
struct LossyArray<Element: Decodable & Sendable>: Decodable, Sendable {
let elements: [Element]
private struct Skip: Decodable { // reads nothing: just moves past one item
init(from decoder: Decoder) throws {}
}
init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
var result: [Element] = []
while !container.isAtEnd {
if let item = try? container.decode(Element.self) {
result.append(item)
} else {
_ = try container.decode(Skip.self) // skip the bad item, or we loop forever
}
}
elements = result
}
}- Log decoding errors with the type name and coding path. They are almost always a contract change.
4. Testing
Nothing in the layer needs the real network. The transport is a protocol, so tests use a fake:
actor MockTransport: HTTPTransport {
private let handler: @Sendable (URLRequest) -> (Int, Data)
private(set) var requests: [URLRequest] = []
init(handler: @escaping @Sendable (URLRequest) -> (Int, Data)) {
self.handler = handler
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
requests.append(request)
let (status, data) = handler(request)
let response = HTTPURLResponse(url: request.url!, statusCode: status,
httpVersion: nil, headerFields: nil)!
return (data, response)
}
}The most valuable test: many parallel 401s cause one refresh.
import Testing
@Test func parallel401sRefreshOnlyOnce() async throws {
let refresher = CountingRefresher(returning: .fresh) // test helper: counts calls
let auth = AuthManager(tokens: .rejectedByServer, refresher: refresher, onLogout: {})
let transport = MockTransport { request in
request.value(forHTTPHeaderField: "Authorization") == "Bearer fresh"
? (200, Data(#"{"id":"1","name":"Ana"}"#.utf8))
: (401, Data())
}
let client = APIClient(baseURL: URL(string: "https://api.test")!,
transport: transport,
middlewares: [AuthMiddleware(auth: auth)])
async let a = client.send(GetUser(id: "1"))
async let b = client.send(GetUser(id: "2"))
async let c = client.send(GetUser(id: "3"))
_ = try await (a, b, c)
#expect(await refresher.callCount == 1)
}Other tests:
- Each middleware alone, with a fake
nextclosure. - Retry: inject
sleepthat records delays. Check thatPOSTis not retried. - Decoding: real JSON samples from the server in test fixtures (contract tests).
- SwiftUI previews and UI tests: a
MockTransportthat returns fixture files.
5. Observability and security
Logging middleware. Log method, path, status and time. Never log the Authorization header,
tokens or request bodies with personal data.
import os
struct LoggingMiddleware: Middleware {
private let logger = Logger(subsystem: "app.network", category: "http")
func intercept(_ request: URLRequest, context: RequestContext,
next: Next) async throws -> (Data, HTTPURLResponse) {
let start = ContinuousClock.now
let path = request.url?.path ?? "?"
let (data, response) = try await next(request)
let elapsed = (ContinuousClock.now - start).formatted()
logger.info("\(request.httpMethod ?? "") \(path) -> \(response.statusCode) in \(elapsed)")
return (data, response)
}
}- A headers middleware adds
X-Request-ID. Support can find the same request in server logs. - Track error rate and latency per endpoint.
URLSessionTaskMetricsgives DNS, TLS and transfer times. - Tokens live in the Keychain, not
UserDefaults. UsekSecAttrAccessibleAfterFirstUnlockif background tasks need them. - HTTPS only (App Transport Security). Never put tokens in URLs: URLs end up in logs.
- Certificate pinning (trust only your server's key) blocks some attacks, but a wrong pin after a certificate change breaks the app for everyone. Pin the public key, keep a backup key, and have a remote kill switch.
Trade-offs
| Decision | Option A | Option B | Usual pick |
|---|---|---|---|
| Endpoint style | One big enum API | One type per endpoint | Type per endpoint (scales across teams) |
| Cross-cutting logic | One big send function | Middleware chain | Middleware chain |
| Library | Alamofire | URLSession + small layer | URLSession (fewer dependencies) |
| Async style | Completion handlers / Combine | async/await | async/await |
| Token refresh | Each request refreshes | Single-flight in an actor | Single-flight |
| Retries | Every failure | Idempotent + transient only | Idempotent + transient |
| Decoding failures | Fail the whole list | Lossy arrays | Lossy for lists, strict for single objects |
What interviewers look for
- A typed
Endpointwith an associatedResponse, so callers cannot decode the wrong type. - Small middlewares with a clear order, and you can explain why the order matters.
- Single-flight token refresh in an actor, with correct reentrancy handling.
- Logout only on a rejected refresh token, not on network errors.
- Retries only for idempotent requests and temporary errors, with backoff, jitter and
Retry-After. - One clear error type that the UI can map to messages.
- Testability: transport behind a protocol, injected sleep, fixture-based decoding tests.
- One shared
URLSession, cancellation through structured concurrency. - Security basics: Keychain, no secrets in logs or URLs, pinning trade-offs.
- Follow-ups: request priorities, response caching with
ETag, background uploads, modularizing per feature, and how to roll out a breaking API change.