SwiftQA Random

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

ConcernMechanism
Abstraction boundaryHTTPClient protocol — DIP applied at the networking layer’s edge
Request modelingDeclarative APIRequest value types, not scattered method calls
Error handlingA real domain-specific error enum, not leaked URLError/status codes
Auth/token refreshActor-serialized refresh to avoid concurrent refresh races
RetryExplicit policy distinguishing idempotent/retryable from not
CachingLayered — HTTP-level via URLCache, app-level only where needed
CancellationStructured concurrency, no detached tasks breaking the chain
ObservabilityLogging/metrics as a decorator, careful about sensitive data
Concurrency safetySendable end-to-end since it’s called from many isolation domains at once
TestabilityProtocol-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.”