How do you design a production networking layer?
1. Start with the abstraction, not the implementation
protocol HTTPClient: Sendable {
func send<Response: Decodable>(_ request: APIRequest) async throws -> Response
}
Everything downstream — ViewModels, Repositories — depends on HTTPClient, never on URLSession directly.
2. Model requests as data, not as scattered method calls
protocol APIRequest {
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String] { get }
var queryItems: [URLQueryItem] { get }
var body: Encodable? { get }
}
struct FetchUserRequest: APIRequest {
let userID: String
var path: String { "/users/\(userID)" }
var method: HTTPMethod { .get }
var headers: [String: String] { [:] }
var queryItems: [URLQueryItem] { [] }
var body: Encodable? { nil }
}
This keeps endpoint definitions declarative, colocated with the feature that owns them, and testable in isolation from actual network execution.
3. Centralize request construction and execution
final class URLSessionHTTPClient: HTTPClient {
private let session: URLSession
private let baseURL: URL
private let decoder: JSONDecoder
func send<Response: Decodable>(_ request: APIRequest) async throws -> Response {
let urlRequest = try buildURLRequest(from: request)
let (data, response) = try await session.data(for: urlRequest)
try validate(response, data: data)
return try decoder.decode(Response.self, from: data)
}
}
Centralizing this is what lets you apply cross-cutting concerns (auth headers, logging, retry, error mapping) in exactly one place.
4. Design a real error taxonomy — don’t let raw errors leak upward
enum APIError: Error {
case noConnectivity
case unauthorized
case notFound
case serverError(statusCode: Int)
case decodingFailed(underlying: Error)
case rateLimited(retryAfter: TimeInterval?)
case unknown(underlying: Error)
}
Callers need unauthorized to trigger a re-login flow, noConnectivity to show an offline banner — meaningfully different UX responses.
5. Handle authentication centrally, including token refresh races
actor TokenRefresher {
private var refreshTask: Task<String, Error>?
func validToken() async throws -> String {
if let existing = refreshTask {
return try await existing.value // piggyback on in-flight refresh
}
let task = Task { try await performRefresh() }
refreshTask = task
defer { refreshTask = nil }
return try await task.value
}
}
Using an actor guarantees only one refresh happens at a time, and other requests hitting 401 concurrently await the same in-flight task.
6. Retry policy — deliberate, not ad hoc
Blind retry-on-any-failure is dangerous (retrying a non-idempotent POST can double-submit an order). A real retry policy distinguishes:
- Retryable: timeouts, connectivity blips, 5xx errors, for idempotent methods.
- Not retryable: 4xx client errors, non-idempotent POSTs without an idempotency key.
- Backoff strategy: exponential backoff with jitter, respecting
Retry-After.
7. Caching — layered, and explicit about staleness
In-memory cache for the current session, a disk cache for offline access, explicit invalidation rules. URLCache handles HTTP-level caching automatically if the server sends proper cache headers.
8. Cancellation and structured concurrency
Because we’re building on async/await, request cancellation should be structured — a request tied to a Task that gets cancelled when the underlying Task is cancelled. URLSession’s async/await APIs already integrate with Swift’s cooperative cancellation.
9. Observability — logging, metrics, and request tracing
Request/response logging (careful never to log tokens/PII), timing metrics, and ideally a request ID threaded through for correlating client-side and server-side logs during an incident. Implemented as a decorator/interceptor around the core HTTPClient.
10. Sendable and concurrency-safety for the client itself
Since the networking layer is used from many isolation domains simultaneously, HTTPClient and everything it touches should be Sendable-conformant by design.
11. Testability as a first-class design constraint, not an afterthought
final class MockHTTPClient: HTTPClient {
var stubbedResponse: Result<Any, Error>
func send<Response: Decodable>(_ request: APIRequest) async throws -> Response {
// return stubbed response cast to Response, or throw
}
}
This lets every feature’s Repository/ViewModel be tested without any real network dependency.
Interview-ready summary — the layers, end to end
| Concern | Mechanism |
|---|---|
| Abstraction boundary | HTTPClient protocol — DIP applied at the networking layer’s edge |
| Request modeling | Declarative APIRequest value types, not scattered method calls |
| Error handling | A real domain-specific error enum, not leaked URLError/status codes |
| Auth/token refresh | Actor-serialized refresh to avoid concurrent refresh races |
| Retry | Explicit policy distinguishing idempotent/retryable from not |
| Caching | Layered — HTTP-level via URLCache, app-level only where needed |
| Cancellation | Structured concurrency, no detached tasks breaking the chain |
| Observability | Logging/metrics as a decorator, careful about sensitive data |
| Concurrency safety | Sendable end-to-end since it’s called from many isolation domains at once |
| Testability | Protocol-based mocking + URLProtocol stubbing for integration tests |
One-liner if pressed: “I’d start from a HTTPClient protocol abstraction so the app never depends on URLSession directly, model requests as declarative value types rather than scattered methods, build a real error taxonomy the app can act on instead of leaking raw HTTP/decoding errors, serialize token refresh through an actor to avoid concurrent refresh races, apply a deliberate retry policy that respects idempotency, and keep the whole surface Sendable and protocol-mockable so every feature can test against it without a real network dependency.”