Building a Clean Networking Layer: APIClient, Endpoints, and Error Handling


Copy-pasting URLSession calls across your codebase is fine until the API changes its authentication header, or you need to add retry logic in 12 different places, or you want to write tests without hitting the network. A proper networking layer solves all three problems at once.

This post walks through building a complete, production-grade networking layer from scratch: a type-safe APIEndpoint protocol, a generic APIClient, request interceptors, retry logic with exponential backoff, centralized error handling, and a MockAPIClient for unit tests. We won’t cover WebSockets or background URLSession configurations — those deserve their own posts.

This guide assumes familiarity with Swift concurrency and protocols. If you’re new to networking fundamentals, start with Networking Basics first.

Contents

The Problem

Here’s what a typical codebase looks like before a networking layer exists. You have two view models, each reaching directly for URLSession.shared:

// FilmsViewModel.swift
final class FilmsViewModel: ObservableObject {
    @Published var films: [PixarFilm] = []

    func loadFilms() async throws {
        let url = URL(string: "https://api.pixar.com/v1/films")!
        let (data, _) = try await URLSession.shared.data(from: url)
        films = try JSONDecoder().decode([PixarFilm].self, from: data)
    }
}

// FilmDetailViewModel.swift
final class FilmDetailViewModel: ObservableObject {
    @Published var film: PixarFilm?

    func loadFilm(id: String) async throws {
        var request = URLRequest(url: URL(string: "https://api.pixar.com/v1/films/\(id)")!)
        request.setValue("Bearer \(AuthManager.shared.token)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        film = try JSONDecoder().decode(PixarFilm.self, from: data)
    }
}

On the surface, this works. In practice, it creates four distinct problems:

  1. Authentication is duplicated. The Authorization header is manually applied in some view models and forgotten in others. When the token format changes, you hunt through every call site.
  2. Error handling is inconsistent. One view model checks the status code, another doesn’t. Neither decodes the error body from the server.
  3. Testing requires hitting the network. You cannot inject a fake response without swapping URLSession.shared — which is a global and takes significant ceremony to replace.
  4. Retry logic doesn’t exist. A transient 503 during the Pixar API’s peak traffic window just becomes a user-visible error.

The solution is a layered abstraction: an APIEndpoint type that knows how to build requests, an APIClient protocol that knows how to execute them, and a set of interceptors that enrich every request automatically.

Building the Endpoint Protocol

The first layer is a protocol that represents a single API endpoint as a value type. Rather than building URLRequest objects scattered across your codebase, you define the shape of each endpoint once and let a single factory method do the construction.

Apple Docs: URLRequest — Foundation

import Foundation

// Supported HTTP methods as a type-safe enum
enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case patch = "PATCH"
    case delete = "DELETE"
}

// Every endpoint in your app conforms to this protocol
protocol APIEndpoint {
    var baseURL: URL { get }
    var path: String { get }
    var method: HTTPMethod { get }
    var headers: [String: String] { get }
    var queryItems: [URLQueryItem]? { get }
    var body: (any Encodable)? { get }
}

// Default implementations so conforming types only override what they need
extension APIEndpoint {
    var headers: [String: String] { [:] }
    var queryItems: [URLQueryItem]? { nil }
    var body: (any Encodable)? { nil }

    // Build a URLRequest from the endpoint's properties
    func urlRequest(encoder: JSONEncoder = .init()) throws -> URLRequest {
        var components = URLComponents(
            url: baseURL.appending(path: path),
            resolvingAgainstBaseURL: true
        )
        components?.queryItems = queryItems

        guard let url = components?.url else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.allHTTPHeaderFields = headers

        if let body {
            request.httpBody = try encoder.encode(body)
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }

        return request
    }
}

With the protocol in place, define a concrete enum for the Pixar Films API. Using an enum with associated values gives you exhaustive, compile-time-checked coverage of every endpoint:

enum PixarAPIEndpoint: APIEndpoint {
    case films
    case filmDetail(id: String)
    case characters(filmID: String)
    case rateFilm(id: String, rating: FilmRating)

    var baseURL: URL {
        URL(string: "https://api.pixar.com/v1")!
    }

    var path: String {
        switch self {
        case .films:
            return "/films"
        case .filmDetail(let id):
            return "/films/\(id)"
        case .characters(let filmID):
            return "/films/\(filmID)/characters"
        case .rateFilm(let id, _):
            return "/films/\(id)/rating"
        }
    }

    var method: HTTPMethod {
        switch self {
        case .films, .filmDetail, .characters:
            return .get
        case .rateFilm:
            return .post
        }
    }

    var body: (any Encodable)? {
        switch self {
        case .rateFilm(_, let rating):
            return rating
        default:
            return nil
        }
    }
}

The enum approach has a meaningful advantage over string-based URLs: the compiler catches every call site when you add or rename a case. If the characters endpoint moves to a new path, you change one path property, not twelve view models.

The APIClient

The APIClient is the execution engine. Define it as a protocol so you can swap in a mock at test time without any dependency injection framework:

// The protocol your view models depend on — never the concrete type
protocol APIClient: Sendable {
    func request<T: Decodable & Sendable>(
        _ endpoint: some APIEndpoint,
        as type: T.Type
    ) async throws -> T
}

Apple Docs: URLSession — Foundation

The live implementation wraps URLSession and delegates request construction to the endpoint:

final class LiveAPIClient: APIClient {
    private let session: URLSession
    private let decoder: JSONDecoder
    private let interceptors: [any RequestInterceptor]

    init(
        session: URLSession = .shared,
        decoder: JSONDecoder = .iso8601,
        interceptors: [any RequestInterceptor] = []
    ) {
        self.session = session
        self.decoder = decoder
        self.interceptors = interceptors
    }

    func request<T: Decodable & Sendable>(
        _ endpoint: some APIEndpoint,
        as type: T.Type = T.self
    ) async throws -> T {
        var urlRequest = try endpoint.urlRequest()

        // Run each interceptor in order, allowing them to mutate the request
        for interceptor in interceptors {
            urlRequest = try await interceptor.intercept(urlRequest)
        }

        let (data, response) = try await session.data(for: urlRequest)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        try validate(response: httpResponse, data: data)

        return try decoder.decode(T.self, from: data)
    }

    // Validate status codes and decode error bodies from the server
    private func validate(response: HTTPURLResponse, data: Data) throws {
        switch response.statusCode {
        case 200..<300:
            return // Success — do nothing
        case 401:
            throw NetworkError.unauthorized
        case 404:
            throw NetworkError.notFound
        case 429:
            let retryAfter = response.value(forHTTPHeaderField: "Retry-After")
                .flatMap(Double.init)
            throw NetworkError.rateLimited(retryAfter: retryAfter)
        case 400..<500:
            // Try to decode a structured error body from the server
            let apiError = try? JSONDecoder().decode(APIErrorResponse.self, from: data)
            throw NetworkError.clientError(
                statusCode: response.statusCode,
                message: apiError?.message
            )
        case 500..<600:
            throw NetworkError.serverError(statusCode: response.statusCode)
        default:
            throw NetworkError.unexpectedStatusCode(response.statusCode)
        }
    }
}

Notice that LiveAPIClient is final. It’s not designed for subclassing — the protocol boundary is the extension point. Also note Sendable conformance on both the protocol and the response type: Swift 6’s strict concurrency checking requires this when passing values across actor boundaries.

A convenience extension on JSONDecoder keeps the ISO 8601 date formatting that most REST APIs use:

extension JSONDecoder {
    static var iso8601: JSONDecoder {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }
}

With this in place, a view model no longer knows anything about URLSession, authentication headers, or JSON decoding strategy. It knows only about APIClient:

@available(iOS 17.0, *)
@Observable
@MainActor
final class FilmsViewModel {
    var films: [PixarFilm] = []
    var error: NetworkError?

    private let client: any APIClient

    init(client: any APIClient) {
        self.client = client
    }

    func loadFilms() async {
        do {
            films = try await client.request(PixarAPIEndpoint.films, as: [PixarFilm].self)
        } catch let networkError as NetworkError {
            error = networkError
        } catch {
            self.error = .unknown(error)
        }
    }
}

Request Interceptors

Interceptors solve the authentication duplication problem. They’re middleware that runs before every request, with the ability to read and mutate the URLRequest. Define the protocol:

protocol RequestInterceptor: Sendable {
    func intercept(_ request: URLRequest) async throws -> URLRequest
}

An authentication interceptor injects the Authorization header from a thread-safe token provider:

actor TokenStore {
    private var accessToken: String?

    func setToken(_ token: String) {
        accessToken = token
    }

    func token() -> String? {
        accessToken
    }
}

struct AuthInterceptor: RequestInterceptor {
    private let tokenStore: TokenStore

    init(tokenStore: TokenStore) {
        self.tokenStore = tokenStore
    }

    func intercept(_ request: URLRequest) async throws -> URLRequest {
        guard let token = await tokenStore.token() else {
            throw NetworkError.unauthorized
        }
        var mutated = request
        mutated.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        return mutated
    }
}

A logging interceptor observes requests without modifying them — useful during development and for crash reporting integrations:

struct LoggingInterceptor: RequestInterceptor {
    func intercept(_ request: URLRequest) async throws -> URLRequest {
        let method = request.httpMethod ?? "UNKNOWN"
        let url = request.url?.absoluteString ?? "nil"
        print("[\(method)] \(url)")
        return request // Logging interceptors pass the request through unchanged
    }
}

Compose interceptors at the call site when constructing LiveAPIClient:

let tokenStore = TokenStore()
let client = LiveAPIClient(
    interceptors: [
        AuthInterceptor(tokenStore: tokenStore),
        LoggingInterceptor()
    ]
)

Interceptors run in the order they’re provided. Authentication runs first so subsequent interceptors can observe the finalized request.

Tip: Keep interceptors focused on a single concern. An interceptor that both adds authentication and logs the request is harder to test and harder to disable selectively.

Retry Logic with Exponential Backoff

Transient failures — a momentary 503, a dropped connection during peak Pixar release traffic — should not be user-visible. Wrap your APIClient calls in a retry helper with exponential backoff:

// A free function that wraps any throwing async operation with retry logic
func withRetry<T: Sendable>(
    maxAttempts: Int = 3,
    baseDelay: Duration = .milliseconds(500),
    operation: @Sendable () async throws -> T
) async throws -> T {
    var lastError: Error?

    for attempt in 0..<maxAttempts {
        do {
            return try await operation()
        } catch let error as NetworkError {
            // Don't retry client errors — they won't resolve themselves
            if case .clientError = error { throw error }
            if case .unauthorized = error { throw error }
            if case .notFound = error { throw error }

            lastError = error

            if attempt < maxAttempts - 1 {
                // Exponential backoff: 500ms, 1000ms, 2000ms
                let delay = baseDelay * Double(pow(2.0, Double(attempt)))
                try await Task.sleep(for: delay)
            }
        }
    }

    throw lastError ?? NetworkError.unknown(URLError(.timedOut))
}

Apple Docs: Task.sleep(for:tolerance:clock:) — Swift Concurrency

Using Duration from Swift 5.7+ keeps the API modern and testable with custom clocks if needed. The retry helper is deliberately decoupled from APIClient — you choose which call sites warrant retrying, rather than retrying blindly on every request:

func loadFilms() async {
    do {
        films = try await withRetry(maxAttempts: 3) { [client] in
            try await client.request(PixarAPIEndpoint.films, as: [PixarFilm].self)
        }
    } catch {
        self.error = error as? NetworkError
    }
}

Centralized Error Handling

A typed NetworkError enum gives you a single, exhaustive type for all networking failures. This is the contract between your networking layer and the rest of the app:

enum NetworkError: Error, LocalizedError, @unchecked Sendable {
    case invalidURL
    case invalidResponse
    case unauthorized
    case notFound
    case rateLimited(retryAfter: Double?)
    case clientError(statusCode: Int, message: String?)
    case serverError(statusCode: Int)
    case unexpectedStatusCode(Int)
    case decodingFailure(DecodingError)
    case unknown(Error)

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "The request URL was malformed."
        case .unauthorized:
            return "Your session has expired. Please sign in again."
        case .notFound:
            return "The requested Pixar resource could not be found."
        case .rateLimited(let retryAfter):
            if let seconds = retryAfter {
                return "Too many requests. Try again in \(Int(seconds)) seconds."
            }
            return "Too many requests. Please slow down."
        case .clientError(_, let message):
            return message ?? "An unexpected error occurred."
        case .serverError(let code):
            return "Server error \(code). The Pixar API team has been notified."
        case .decodingFailure(let error):
            return "Failed to parse the server response: \(error.localizedDescription)"
        default:
            return "An unexpected error occurred."
        }
    }
}

// The structured error body the Pixar API returns for 4xx responses
struct APIErrorResponse: Decodable {
    let message: String
    let code: String?
}

LocalizedError conformance means networkError.localizedDescription produces user-presentable strings, and SwiftUI’s alert(error:) modifier works without additional mapping.

Warning: Avoid catching NetworkError at every call site and mapping it to a different error type. Define your UI error states in terms of NetworkError directly, or use a single ErrorMapper at the boundary between your networking layer and presentation layer. Multiple ad-hoc mappings lead to the same fragmentation problem you started with.

MockAPIClient for Unit Tests

Because view models depend on the APIClient protocol, not LiveAPIClient, writing a mock is straightforward:

// A mock that lets tests inject pre-baked responses or errors
// @unchecked because we only mutate during test setup (single-threaded)
final class MockAPIClient: APIClient, @unchecked Sendable {
    // Store responses keyed by the expected return type's name
    var responses: [String: Any] = [:]
    var errors: [String: Error] = [:]

    // Track calls for assertion
    private(set) var requestedEndpoints: [String] = []

    func request<T: Decodable & Sendable>(
        _ endpoint: some APIEndpoint,
        as type: T.Type
    ) async throws -> T {
        let key = String(describing: T.self)
        requestedEndpoints.append(endpoint.path)

        if let error = errors[key] {
            throw error
        }

        guard let response = responses[key] as? T else {
            throw NetworkError.unknown(URLError(.cannotParseResponse))
        }

        return response
    }
}

A unit test for FilmsViewModel becomes clean and fast — no network, no async ceremony beyond async throws:

import Testing

@Suite("FilmsViewModel")
struct FilmsViewModelTests {
    @Test("loads films from the API client")
    func loadsFilms() async throws {
        let mock = MockAPIClient()
        mock.responses[String(describing: [PixarFilm].self)] = [
            PixarFilm(id: "toy-story", title: "Toy Story", year: 1995),
            PixarFilm(id: "finding-nemo", title: "Finding Nemo", year: 2003)
        ]

        let viewModel = await FilmsViewModel(client: mock)
        await viewModel.loadFilms()

        let films = await viewModel.films
        #expect(films.count == 2)
        #expect(films.first?.title == "Toy Story")
    }

    @Test("exposes network error on failure")
    func exposesErrorOnFailure() async throws {
        let mock = MockAPIClient()
        mock.errors[String(describing: [PixarFilm].self)] = NetworkError.serverError(statusCode: 503)

        let viewModel = await FilmsViewModel(client: mock)
        await viewModel.loadFilms()

        let error = await viewModel.error
        if case .serverError(let code) = error {
            #expect(code == 503)
        } else {
            Issue.record("Expected serverError, got \(String(describing: error))")
        }
    }
}

Apple Docs: Testing — Swift Testing framework (Xcode 16+)

Advanced Usage

Multipart Upload Support

File uploads require multipart/form-data encoding, which Encodable doesn’t cover. Extend the endpoint protocol with an optional body data provider:

protocol MultipartEndpoint: APIEndpoint {
    var multipartData: MultipartFormData { get }
}

struct MultipartFormData {
    struct Part {
        let name: String
        let data: Data
        let filename: String?
        let mimeType: String
    }
    let parts: [Part]
    let boundary: String = UUID().uuidString
}

Handle this case in LiveAPIClient.request before building the request body, or create a dedicated upload method on the client protocol.

Request Deduplication

When a view appears multiple times in a navigation stack — or a user rapidly switches tabs — you can end up with multiple in-flight requests for identical endpoints. Track them with an actor:

actor RequestDeduplicator {
    private var inflight: [String: Task<Data, Error>] = [:]

    func deduplicate(
        key: String,
        operation: @escaping @Sendable () async throws -> Data
    ) async throws -> Data {
        if let existing = inflight[key] {
            return try await existing.value // Await the already-running task
        }

        let task = Task { try await operation() }
        inflight[key] = task

        defer { inflight.removeValue(forKey: key) }

        return try await task.value
    }
}

Inject the deduplicator into LiveAPIClient and wrap the session.data(for:) call.

Response Caching

URLCache is built into URLSession and respects Cache-Control headers automatically. Opt in by configuring the session’s cache policy:

let cache = URLCache(
    memoryCapacity: 10 * 1024 * 1024,   // 10 MB in-memory
    diskCapacity: 50 * 1024 * 1024,      // 50 MB on-disk
    diskPath: "pixar_api_cache"
)

let configuration = URLSessionConfiguration.default
configuration.urlCache = cache
configuration.requestCachePolicy = .returnCacheDataElseLoad

let session = URLSession(configuration: configuration)

For fine-grained control, set cachePolicy per URLRequest inside APIEndpoint.urlRequest().

URLProtocol-Level Mocking

When you need to verify the exact URLRequest sent to the server — headers, body encoding, query parameters — URLProtocol lets you intercept at the session level without touching your APIClient protocol:

class PixarAPIStubProtocol: URLProtocol {
    static var stubbedResponse: (Data, HTTPURLResponse)?

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        guard let (data, response) = Self.stubbedResponse else { return }
        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: data)
        client?.urlProtocolDidFinishLoading(self)
    }

    override func stopLoading() {}
}

Configure the session in tests:

let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [PixarAPIStubProtocol.self]
let session = URLSession(configuration: config)
let client = LiveAPIClient(session: session)

This approach lets you test LiveAPIClient itself — including the status code validation and decoding logic — without a real server.

Note: Certificate pinning (validating the server’s TLS certificate at the app level) is worth adding if your app handles sensitive user data. It’s covered in depth in the upcoming App Security post.

When to Use (and When Not To)

ScenarioRecommendation
App with 3+ network call sitesBuild the full layer — the protocol boundary pays dividends at the first refactor
Prototype or MVP with 1–2 endpointsURLSession directly is fine; extract to an APIClient once you have a third endpoint
Multiple environments (dev/staging/prod)APIEndpoint.baseURL makes environment switching a one-line change
Unit testing view modelsProtocol-based APIClient is non-negotiable — mocking URLSession.shared is painful
SwiftData + server syncConsider whether a dedicated SyncEngine layer sits above APIClient
High request volume with duplicatesAdd the RequestDeduplicator actor to avoid redundant in-flight tasks
Certificate pinning requiredImplement URLAuthenticationChallenge in a URLSessionDelegate; don’t add it to APIClient

Summary

  • Scatter URLSession calls across your codebase and you guarantee authentication bugs, inconsistent error handling, and untestable view models.
  • The APIEndpoint protocol gives each API endpoint a single definition point — path, method, headers, and body in one place, with a urlRequest() factory that builds the URLRequest.
  • APIClient is a protocol, not a class. LiveAPIClient is the production implementation; MockAPIClient is the test double. View models depend on the protocol.
  • Request interceptors solve cross-cutting concerns — authentication, logging, metrics — without polluting endpoint definitions or view models.
  • withRetry with exponential backoff belongs at the call site, not inside APIClient, so you control which operations are worth retrying.
  • NetworkError with LocalizedError conformance gives SwiftUI’s error presentation modifiers a clean, typed, user-facing error surface.

With a solid networking layer in place, the next architectural boundary to enforce is the module boundary. Modular App Architecture: Splitting Your App into Swift Packages shows how to put NetworkingKit in its own local package, making it shareable across multiple app targets and dramatically improving build times.