Building a Testable Network Layer with Alamofire
A protocol-oriented approach to HTTP networking in Swift using async/await, dependency injection, and proper error handling
A protocol-oriented approach to HTTP networking in Swift using async/await, dependency injection, and proper error handling
A well-designed network layer is foundational to any iOS application that communicates with a backend API. This article demonstrates how to build a reusable, testable HTTP client using Alamofire, Swift Concurrency, and protocol-oriented design.
Before writing code, let’s establish what we’re building toward:
Codable constraintsStart with a protocol that defines the contract for any HTTP client implementation:
import Foundation
protocol HTTPClient: Sendable {
func get<T: Decodable>(_ endpoint: String) async throws -> T
func post<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T
func put<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T
func patch<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T
func delete<T: Decodable>(_ endpoint: String) async throws -> T
}
This protocol allows you to:
Here’s the Alamofire-backed implementation:
import Alamofire
import Foundation
final class NetworkClient: HTTPClient, @unchecked Sendable {
private let baseURL: URL
private let session: Session
private let tokenProvider: TokenProvider
init(
baseURL: URL,
tokenProvider: TokenProvider,
session: Session = .default
) {
self.baseURL = baseURL
self.tokenProvider = tokenProvider
self.session = session
}
func get<T: Decodable>(_ endpoint: String) async throws -> T {
try await request(endpoint, method: .get)
}
func post<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
try await request(endpoint, method: .post, body: body)
}
func put<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
try await request(endpoint, method: .put, body: body)
}
func patch<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
try await request(endpoint, method: .patch, body: body)
}
func delete<T: Decodable>(_ endpoint: String) async throws -> T {
try await request(endpoint, method: .delete)
}
private func request<T: Decodable>(
_ endpoint: String,
method: HTTPMethod,
body: (any Encodable)? = nil
) async throws -> T {
let url = baseURL.appendingPathComponent(endpoint)
let task = session.request(
url,
method: method,
parameters: body.map { AnyEncodable($0) },
encoder: JSONParameterEncoder.default,
interceptor: AuthInterceptor(tokenProvider: tokenProvider)
)
.validate()
.serializingDecodable(T.self)
let response = await task.response
switch response.result {
case .success(let value):
return value
case .failure(let error):
throw NetworkError(afError: error, response: response.response)
}
}
}
The interceptor handles JWT attachment automatically for every request:
import Alamofire
import Foundation
protocol TokenProvider: Sendable {
func currentToken() async -> String?
}
final class KeychainTokenProvider: TokenProvider {
func currentToken() async -> String? {
// In production, use Keychain instead of UserDefaults
UserDefaults.standard.string(forKey: "auth_token")
}
}
final class AuthInterceptor: RequestInterceptor {
private let tokenProvider: TokenProvider
init(tokenProvider: TokenProvider) {
self.tokenProvider = tokenProvider
}
func adapt(
_ urlRequest: URLRequest,
for session: Session,
completion: @escaping (Result<URLRequest, Error>) -> Void
) {
Task {
var request = urlRequest
if let token = await tokenProvider.currentToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
completion(.success(request))
}
}
}
Note: For production apps, store tokens in the Keychain using a library like KeychainAccess, not UserDefaults.
Wrap Alamofire errors into a domain-specific error type:
import Alamofire
import Foundation
enum NetworkError: Error, LocalizedError {
case unauthorized
case notFound
case serverError(statusCode: Int)
case decodingFailed(Error)
case networkUnavailable
case unknown(Error)
init(afError: AFError, response: HTTPURLResponse?) {
switch response?.statusCode {
case 401:
self = .unauthorized
case 404:
self = .notFound
case 500...599:
self = .serverError(statusCode: response?.statusCode ?? 500)
default:
if case .responseSerializationFailed(let reason) = afError,
case .decodingFailed(let decodingError) = reason {
self = .decodingFailed(decodingError)
} else if case .sessionTaskFailed(let urlError as URLError) = afError,
urlError.code == .notConnectedToInternet {
self = .networkUnavailable
} else {
self = .unknown(afError)
}
}
}
var errorDescription: String? {
switch self {
case .unauthorized:
return "Your session has expired. Please sign in again."
case .notFound:
return "The requested resource was not found."
case .serverError(let code):
return "Server error (\(code)). Please try again later."
case .decodingFailed:
return "Failed to process the server response."
case .networkUnavailable:
return "No internet connection. Please check your network settings."
case .unknown:
return "An unexpected error occurred."
}
}
}
For encoding generic request bodies:
struct AnyEncodable: Encodable {
private let encode: (Encoder) throws -> Void
init<T: Encodable>(_ value: T) {
encode = value.encode
}
func encode(to encoder: Encoder) throws {
try encode(encoder)
}
}
With the protocol-based design, view models accept the client as a dependency:
import SwiftUI
struct Item: Codable, Identifiable {
let id: String
let name: String
}
@MainActor
final class ItemListViewModel: ObservableObject {
@Published private(set) var items: [Item] = []
@Published private(set) var error: NetworkError?
@Published private(set) var isLoading = false
private let client: HTTPClient
init(client: HTTPClient) {
self.client = client
}
func loadItems() async {
isLoading = true
error = nil
do {
items = try await client.get("/items")
} catch let networkError as NetworkError {
error = networkError
} catch {
self.error = .unknown(error)
}
isLoading = false
}
}
struct ItemListView: View {
@StateObject private var viewModel: ItemListViewModel
init(client: HTTPClient) {
_viewModel = StateObject(wrappedValue: ItemListViewModel(client: client))
}
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.error {
ContentUnavailableView(
"Unable to Load",
systemImage: "exclamationmark.triangle",
description: Text(error.localizedDescription)
)
} else {
List(viewModel.items) { item in
Text(item.name)
}
}
}
.navigationTitle("Items")
.task {
await viewModel.loadItems()
}
}
}
}
The protocol abstraction makes unit testing straightforward:
final class MockHTTPClient: HTTPClient {
var getResult: Any?
var shouldThrow: NetworkError?
func get<T: Decodable>(_ endpoint: String) async throws -> T {
if let error = shouldThrow { throw error }
guard let result = getResult as? T else {
fatalError("Mock not configured for type \(T.self)")
}
return result
}
func post<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
fatalError("Not implemented")
}
func put<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
fatalError("Not implemented")
}
func patch<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
fatalError("Not implemented")
}
func delete<T: Decodable>(_ endpoint: String) async throws -> T {
fatalError("Not implemented")
}
}
@Test func loadItems_success() async {
let mockClient = MockHTTPClient()
mockClient.getResult = [Item(id: "1", name: "Test")]
let viewModel = await ItemListViewModel(client: mockClient)
await viewModel.loadItems()
#expect(await viewModel.items.count == 1)
#expect(await viewModel.error == nil)
}
@Test func loadItems_networkError() async {
let mockClient = MockHTTPClient()
mockClient.shouldThrow = .networkUnavailable
let viewModel = await ItemListViewModel(client: mockClient)
await viewModel.loadItems()
#expect(await viewModel.items.isEmpty)
#expect(await viewModel.error == .networkUnavailable)
}
Configure the client at app launch:
import SwiftUI
@main
struct MyApp: App {
private let httpClient: HTTPClient
init() {
guard let baseURL = URL(string: Configuration.apiBaseURL) else {
fatalError("Invalid API base URL")
}
httpClient = NetworkClient(
baseURL: baseURL,
tokenProvider: KeychainTokenProvider()
)
}
var body: some Scene {
WindowGroup {
ItemListView(client: httpClient)
}
}
}
This implementation covers the essentials, but production apps may also need:
RequestRetrier to handle 401 responses and refresh expired tokensTask instances when views disappearEventMonitor for debuggingThe protocol-oriented foundation makes adding these features straightforward without changing calling code.
A protocol-oriented approach to HTTP networking in Swift using async/await, dependency injection, and proper error handling
A proof-of-concept for building offline-first iOS apps with Codable conforming models
Build a real-time chatroom with Vapor and SwiftUI using WebSockets