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
- Building the Endpoint Protocol
- The APIClient
- Request Interceptors
- Retry Logic with Exponential Backoff
- Centralized Error Handling
- MockAPIClient for Unit Tests
- Advanced Usage
- When to Use (and When Not To)
- Summary
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:
- Authentication is duplicated. The
Authorizationheader is manually applied in some view models and forgotten in others. When the token format changes, you hunt through every call site. - Error handling is inconsistent. One view model checks the status code, another doesn’t. Neither decodes the error body from the server.
- 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. - 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
NetworkErrorat every call site and mapping it to a different error type. Define your UI error states in terms ofNetworkErrordirectly, or use a singleErrorMapperat 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)
| Scenario | Recommendation |
|---|---|
| App with 3+ network call sites | Build the full layer — the protocol boundary pays dividends at the first refactor |
| Prototype or MVP with 1–2 endpoints | URLSession 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 models | Protocol-based APIClient is non-negotiable — mocking URLSession.shared is painful |
| SwiftData + server sync | Consider whether a dedicated SyncEngine layer sits above APIClient |
| High request volume with duplicates | Add the RequestDeduplicator actor to avoid redundant in-flight tasks |
| Certificate pinning required | Implement URLAuthenticationChallenge in a URLSessionDelegate; don’t add it to APIClient |
Summary
- Scatter
URLSessioncalls across your codebase and you guarantee authentication bugs, inconsistent error handling, and untestable view models. - The
APIEndpointprotocol gives each API endpoint a single definition point — path, method, headers, and body in one place, with aurlRequest()factory that builds theURLRequest. APIClientis a protocol, not a class.LiveAPIClientis the production implementation;MockAPIClientis 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.
withRetrywith exponential backoff belongs at the call site, not insideAPIClient, so you control which operations are worth retrying.NetworkErrorwithLocalizedErrorconformance 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.