Networking for Large-Scale iOS Applications

Networking serves as the backbone of mobile applications, facilitating seamless communication and enabling rich user experiences. However, it also introduces complexity, especially when dealing with modular applications that are built from independent features.

Modularity offers many benefits, such as a faster development cycle and better scalability. But it also requires a robust approach to handle cross-cutting concerns among features. Since networking is a cross-cutting concern, it cannot be easily encapsulated in any of them.

In this article, we explore a lightweight approach for dealing with networking in modular iOS applications.

Naive Approach

First, let's take a look at the naive approach, where multiple independent features directly depend on the networking package.

Image

Even though the latter encapsulates the ability to communicate over the network, it adds up to the total build time of each feature, since compiling the feature requires compilation of the entire network package as well. Especially when working in feature teams, it is desirable to minimize the amount of direct dependencies so that we can benefit from shorter build times and a fast feedback loop.

Lightweight Approach in modular Applications

To ensure that we do not need to compile the network-related code when working on a feature, we introduce a lightweight abstraction called HTTPClient that is exposed to every feature but does not contain a concrete implementation.

/// Abstraction for executing HTTP network requests.
public protocol HTTPClient {
    /// Executes the given request and returns the received data and response including the StatusCode.
    /// - Parameter request: The request to be executed.
    /// - Returns: The received data and response (including the StatusCode).
    @discardableResult
    func load(request: HTTPRequest) async throws -> (Data, HTTPResponse)
}

The abstraction only consists of the load(request:) method, that given an HTTPRequest returns the received data along with the HTTPResponse. The HTTPRequest contains all information necessary to perform the request, like the method, path, headers and queryParameters. In addition, the body parameter is used to specify the content that is transmitted with the request.

/// Abstraction describing an HTTP network request.
public protocol HTTPRequest {
    /// The HTTP method of the request.
    var method: HTTPMethod { get }
    /// The path of the request endpoint.
    var path: HTTPPath { get }
    /// The endpoint version of the request.
    var version: HTTPVersion { get }
    /// The headers to be transmitted with the request.
    var headers: HTTPHeaders? { get }
    /// The query parameters to be transmitted with the request.
    var queryParameter: HTTPQueryParameter? { get }
    /// The content to be transmitted with the request.
    var body: Data? { get }
}

Some parameters are declared optional since they may not be necessary for every request. Hence, developers have the flexibility to omit these parameters, streamlining the implementation and improving code readability.

extension HTTPRequest {
    public var headers: HTTPHeaders? { nil }
    public var queryParameter: HTTPQueryParameter? { nil }
    public var body: Data? { nil }
}

Let's briefly go through the meaning of each of the parameters.

First, the HTTPMethod enumeration corresponds to the methods permitted for HTTP requests. Alongside GET for data retrieval, it also includes POST for creating a resource, as well as PUT and PATCH for updating the resource, and DELETE for removing it when no longer needed.

/// The HTTP method of the network request.
public enum HTTPMethod: String, Equatable {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case patch = "PATCH"
    case delete = "DELETE"
}

extension HTTPMethod {
    public var name: String {
        rawValue
    }
}

Next, the HTTPPath in conjunction with the HTTPVersion specifies the endpoint targeted by the request. Both are defined as type aliases of String to better derive a semantic meaning when looking at the type itself.

/// The path of the network request.
public typealias HTTPPath = String
/// The version of the HTTP endpoint.
public typealias HTTPVersion = String

Next, HTTPHeader defines standard headers that are included in most requests. For instance, the contentType attribute specifies the type of content being transmitted in the request body.

/// Standard headers that can be transmitted in requests to the backend.
public enum HTTPHeader: String {
    /// The data format accepted by the client (e.g., `application/json`).
    case accept = "Accept"
    /// The type of content being sent in the body (e.g., `application/json`).
    case contentType = "Content-Type"
}

Finally, similar to HTTPPath, both HTTPHeaders and HTTPQueryParameter utilize type aliases to give meaning to the underlying String key-value pairs.

/// Dictionary of headers sent or received as key-value pairs in network requests.
public typealias HTTPHeaders = [String: String]
/// Key-value pairs transmitted as query parameters in network requests.
public typealias HTTPQueryParameter = [String: String]

Having referred to the properties of an HTTPRequest, let's now shift our focus to the HTTPResponse that is received upon successful execution of load(request:).

/// Contains the response to a network request.
public struct HTTPResponse {
    /// The StatusCode of the response.
    public let statusCode: HTTPStatusCode
    /// The headers of the response.
    public let headers: HTTPHeaders?

    /// Initializes a `HTTPResponse` for a given StatusCode and Header.
    /// - Parameters:
    ///   - statusCode: The StatusCode of the response.
    ///   - headers: The headers of the response.
    public init(statusCode: HTTPStatusCode, headers: HTTPHeaders? = nil) {
        self.statusCode = statusCode
        self.headers = headers
    }
}

/// The StatusCode contained in a response to a network request.
public typealias HTTPStatusCode = Int

In contrast to the request, the HTTPResponse only comprises two properties: the statusCode and headers. While the status code communicates the outcome of the request, the headers are optional and enable the server to provide supplementary information to the client alongside the response.

Furthermore, extensions are implemented to streamline the determination of the response's nature, categorizing it into types such as informational, successful, redirection, client error, or server error. This eliminates the need to memorize status code ranges.

extension HTTPResponse {
    /// Status indicating whether it is an informational response.
    /// - seealso: For more information, see [mdm web docs - Information responses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#information_responses)
    public var isInformational: Bool {
        (100 ..< 200).contains(statusCode)
    }

    /// Status indicating whether it is a successful response.
    /// - seealso: For more information, see [mdm web docs - Successful responses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#successful_responses)
    public var isSuccess: Bool {
        (200 ..< 300).contains(statusCode)
    }

    /// Status indicating whether it is a redirection response.
    /// - seealso: For more information, see [mdm web docs - Redirection messages](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages)
    public var isRedirectional: Bool {
        (300 ..< 400).contains(statusCode)
    }

    /// Status indicating whether it is a client error response.
    /// - seealso: For more information, see [mdm web docs - Client error responses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses)
    public var isClientError: Bool {
        (400 ..< 500).contains(statusCode)
    }

    /// Status indicating whether it is a server error response.
    /// - seealso: For more information, see [mdm web docs - Server error responses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#server_error_responses)
    public var isServerError: Bool {
        (500 ..< 600).contains(statusCode)
    }

    /// Status indicating whether it is an error response.
    public var isError: Bool {
        (400 ..< 600).contains(statusCode)
    }
}

As a result, relying solely on the HTTPClient abstraction has a negligible impact on the feature's build time. Moreover, the feature avoids dealing with 3rd party dependencies like Moya, Alamofire or Apple's URLSession. Instead, it only uses the abstraction to communicate over the network and assumes that a concrete implementation will be provided by another entity. This assumption is key for decoupling features from cross-cutting concerns and developing them in isolation.

Image

Infrastructure: "URLSessionInfrastructure"

Still, when integrating the feature, we need to ensure that its requirements are fulfilled by a concrete implementation. The latter is provided by infrastructure modules that may use a third-party library to achieve the intended result. For instance, the URLSessionInfrastructure package may use Apple's URLSession to execute the request.

Image

In this manner, only the infrastructure package directly relies on third-party frameworks, such that each feature stays agnostic from infrastructure details. Below you can find an example implementation of the URLSessionClient:

import Foundation

public final class URLSessionClient: HTTPClient {
    enum Error: Swift.Error {
        case noHttpURLResponse
        case invalidHeaderArguments
    }

    private let baseURL: URL
    private let session: URLSession

    public init(baseURL: URL, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session
    }

    public func load(request: HTTPRequest) async throws -> (Data, HTTPResponse) {
        let url = baseURL
            .appending(path: request.version)
            .appending(path: request.path)

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.method.name
        urlRequest.httpBody = request.body
        urlRequest.allHTTPHeaderFields = request.headers
        var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        components?.queryItems = request.queryParameter?.reduce(into: [URLQueryItem]()) { current, next in
            let item = URLQueryItem(name: next.key, value: next.value)
            current.append(item)
        }

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

        guard let httpURLResponse = response as? HTTPURLResponse else {
            throw Error.noHttpURLResponse
        }

        guard let headers = httpURLResponse.allHeaderFields as? [String: String] else {
            throw Error.invalidHeaderArguments
        }

        let HttpResponse = HTTPResponse(statusCode: httpURLResponse.statusCode, headers: headers)
        return (data, HttpResponse)
    }
}

First, the load(request:) method constructs a URLRequest along with its query items based on the information given in the HTTPRequest. It's important to note that URLRequest is considered an infrastructure detail, as it's provided by Apple's URLSession. Second, the session is used to execute the request and wait for the reception of a response. Finally, the received response is interpreted as an HTTPURLResponse from which headers and statusCode are extracted.

By separating the feature from infrastructure details, we can integrate all components within the composition root. The latter serves as the translation layer between modules without requiring them to have knowledge of each other. This way, features can be developed in isolation by different feature teams, which is essential for large-scale modular applications.

Image

Adding Functionality: Decorators

Especially within large-scale applications, we frequently encounter new or evolving business requirements, such as logging or authentication. Instead of modifying the infrastructure module, we can utilize the decorator pattern to add functionality without altering the underlying implementation. Consider the AuthenticatedHTTPClient as an illustration: it provides the same interface as the HTTPClient, yet it injects a Bearer token with every request. Through this approach, decorations are linked together until the desired behavior is achieved.

Image

Let's take a moment to highlight four decorations of the HTTPClient to deal with authentication, error handling, header injection as well as logging

Authentication

First, let's start by taking a look at how we can add authentication to an existing HTTPClient. Given the client, as well as a closure that returns an access token, the AuthenticatedHTTPClient injects the token as an Authentication header into every request. Note that the closure providing the token is asynchronous and may return an error when the token retrieval fails. In case, no token could be retrieved, the client throws the missingAccessToken error.

/// A `HTTPClient` decorator that extends requests to a client with an authentication token.
public class AuthenticatedHTTPClient: HTTPClient {
    enum Error: Swift.Error {
        /// Error if no AccessToken exists.
        case missingAccessToken
    }

    private let client: HTTPClient
    private let accessToken: () async throws -> String

    /// Decorates requests to a `HTTPClient` with the authentication token.
    /// - Parameters:
    ///   - client: The `HTTPClient` to be decorated.
    ///   - accessToken: A closure providing the AccessToken.
    public init(client: HTTPClient, accessToken: @escaping () async throws -> String) {
        self.client = client
        self.accessToken = accessToken
    }

    /// Executes the given network request.
    /// - Parameter request: The request to be executed.
    /// - Returns: The response including the data to the network request.
    public func load(request: HTTPRequest) async throws -> (Data, HTTPResponse) {
        var modifiedRequest = ModifiedRequest(request)
        var headers = modifiedRequest.headers ?? [:]

        do {
            headers["Authorization"] = try await accessToken()
        } catch {
            throw Error.missingAccessToken
        }

        modifiedRequest.headers = headers
        return try await client.load(request: modifiedRequest)
    }
}

The following extension allows to easily chain the AuthenticatedHttpClient with an existing client:

extension HTTPClient {
    /// Extends requests to a `HTTPClient` with the authentication token.
    /// - Returns: The `HTTPClient` extended with the authentication token.
    public func authenticated(accessToken: @escaping () async throws -> String) -> HTTPClient {
        AuthenticatedHTTPClient(client: self, accessToken: accessToken)
    }
}

Then, an existing client is augmented by calling .authenticated with a closure providing the access token.

URLSessionClient(baseURL: baseURL, session: session)
    .authenticated(
        accessToken: {
            try await Task.sleep(nanoseconds: 1_000_000)
            return "Bearer <Token>"
        }
    )

Note that the access token retrieval is asynchronous because the token may expire. If the token becomes invalid, the client can request a new token using the refresh token from the authentication server.

Error Handling

Like authentication, we can introduce an additional decorator that handles errors for responses with status codes outside 200..<300 by default. However, this does not prevent us from handling the error on the call side. Rather, handlers can be chained to deal with the error suitably, thanks to their composable nature.

/// Error type encompassing errors that may occur during the execution of network requests.
public enum HTTPError: Error, Equatable {
    /// Error for responses to network requests containing a StatusCode not within `200..<300`.
    case httpCode(statusCode: HTTPStatusCode, data: Data)
}

The ErrorHandlingHTTPClient examines the response status code and throws an HTTPError.httpCode(statusCode:data:) error if it falls outside the expected range. This simplifies error handling for the client. Within the feature, where we have a better understanding of the error, we can provide more specific details on how to handle it.

/// A `HTTPClient` decorator implementing error handling for standard errors.
public class ErrorHandlingHTTPClient: HTTPClient {
    private let client: HTTPClient

    /// Decorates a HTTPClient with standard error handling.
    /// - Parameter client: The client to be decorated.
    public init(client: HTTPClient) {
        self.client = client
    }

    /// Executes the given network request.
    /// - Parameter request: The request to be executed.
    /// - Returns: The response including the data to the network request.
    public func load(request: HTTPRequest) async throws -> (Data, HTTPResponse) {
        let (data, response) = try await client.load(request: request)

        if !(200 ..< 300).contains(response.statusCode) {
            throw HTTPError.httpCode(statusCode: response.statusCode, data: data)
        } else {
            return (data, response)
        }
    }
}

extension HTTPClient {
    /// Decorates a `HTTPClient` with standard error handling.
    /// - Returns: The `HTTPClient` extended with standard error handling.
    public func handlingErrors() -> HTTPClient {
        ErrorHandlingHTTPClient(client: self)
    }
}

Similar to the previous extensions, default error handling is added by calling handlingErrors() on an existing client.

URLSessionClient(baseURL: baseURL, session: session)
    .handlingErrors()

Header Injection

Now, we will discuss the HeadersInjectingHTTPClient. This client enables the injection of custom headers into the request.

/// A `HTTPClient` decorator that extends requests with additional HTTP headers.
public class HeadersInjectingHTTPClient: HTTPClient {
    private let client: HTTPClient
    private let headers: HTTPHeaders

    /// Decorates requests to a HTTPClient with additional headers.
    /// - Parameter client: The client to be decorated.
    /// - Parameter headers: The additional headers to be added to requests to the client.
    /// > Warning: Identically named headers that already exist will be overwritten.
    public init(client: HTTPClient, headers: HTTPHeaders) {
        self.client = client
        self.headers = headers
    }

    /// Executes the given network request.
    /// - Parameter request: The request to be executed.
    /// - Returns: The response including the data to the network request.
    public func load(request: HTTPRequest) async throws -> (Data, HTTPResponse) {
        var modifiedRequest = ModifiedRequest(request)
        var headers = modifiedRequest.headers ?? [:]
        self.headers.forEach { header in
            headers[header.key] = header.value
        }
        modifiedRequest.headers = headers
        return try await client.load(request: modifiedRequest)
    }
}

extension HTTPClient {
    /// Decorates requests to a `HTTPClient` with additional headers.
    /// - Returns: The `HTTPClient` extended with the additional headers.
    public func injecting(headers: HTTPHeaders) -> HTTPClient {
        HeadersInjectingHTTPClient(client: self, headers: headers)
    }
}

Custom headers are frequently utilized to provide additional information about the client, such as its version or the operating system on which the application is running. Additionally, the client may need to specify the desired data format for the response. In both of these cases, the required information can be injected by calling the injecting(headers:) extension on an existing client.

URLSessionClient(baseURL: baseURL, session: session)
    .injecting(
        headers: [
            "header1": "value1",
            "header2": "value2",
            "header3": "value3"
        ]
    )

Logging Request and Responses

Finally, we will discuss how to handle logging with the LoggingHTTPClient. Similar to the previous decorators, this client follows the HTTPClient protocol and describes the request and response after the call. Please note that we utilized print statements for the sake of simplicity. In a production-level application, a dedicated logger with an appropriate queuing mechanism would be used to ensure that events are logged in the order in which they occur.

/// A `HTTPClient` decorator that logs requests.
public class LoggingHTTPClient: HTTPClient {
    private let client: HTTPClient

    /// Logs requests to a HTTPClient
    /// - Parameter client: The client to be decorated.
    public init(client: HTTPClient) {
        self.client = client
    }

    /// Executes the given network request.
    /// - Parameter request: The request to be executed.
    /// - Returns: The response including the data to the network request.
    public func load(request: HTTPRequest) async throws -> (Data, HTTPResponse) {
        print(describe(request: request))
        let (data, response) = try await client.load(request: request)
        print(describe(response: response, data: data))
        return (data, response)
    }
}

Below, you can find utility functions that prepare the request and response in a human-readable format. This allows developers to quickly verify whether the request was successful or if the server responded with the expected status code. To enhance the implementation, consider storing the log on disk or exporting it for further investigation.

extension LoggingHTTPClient {
    private func describe(request: HTTPRequest) -> String {
        [
            "Outgoing Network Request ⬆️:",
            "-------------------------------",
            "Method: \(request.method.name)",
            "Path: \(request.path)",
            "Version: \(request.version)",
            request.headers.map { "Headers:\n\(describe(dict: $0))" },
            request.queryParameter.map { "Query-Parameter: \(String(describing: $0))" },
            request.body.map { "Body:\n\(describe(data: $0))" },
        ]
        .compactMap { $0 }
        .joined(separator: "\n")
    }

    private func describe(response: HTTPResponse, data: Data) -> String {
        [
            "Incoming Network Response ⬇️:",
            "------------------------------------------",
            "\(describe(response: response))",
            "Body:",
            "\(describe(data: data))",
        ]
        .joined(separator: "\n")
    }

    private func describe(response: HTTPResponse) -> String {
        [
            "StatusCode: \(response.statusCode)",
            response.headers.map { "Headers:\n\(describe(dict: $0))" },
        ]
        .compactMap { $0 }
        .joined(separator: "\n")
    }

    private func describe(data: Data) -> String {
        guard
            let object = try? JSONSerialization.jsonObject(with: data, options: []),
            let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
            let value = NSString(data: data, encoding: String.Encoding.utf8.rawValue) 
        else {
            return String(data: data, encoding: .utf8) ?? ""
        }
        
        return String(value)
    }

    private func describe(dict: [String: String]) -> String {
        "\(dict.reduce("") { $0 + "    - \($1.key): \($1.value)\n" })"
    }
}

Using the following extension, we can activate logging for all network requests:

extension HTTPClient {
    /// Logs requests to a `HTTPClient`
    /// - Returns: The `HTTPClient` extended with request logging.
    public func loggingRequestAndResponse() -> HTTPClient {
        LoggingHTTPClient(client: self)
    }
}

URLSessionClient(baseURL: baseURL, session: session)
    .loggingRequestAndResponse()

Here is an example of a human-readable printout of a request/response pair. The log clearly breaks down the outgoing request, including its method, path, version, and headers. The incoming response is also detailed, including its status code, headers, and body content.

Outgoing Network Request ⬆️:
-------------------------------
Method: GET
Path: token
Version: v1
Headers:
    - Authorization: Bearer <Token>
    - header1: value1
    - header2: value2
    - header3: value3

Incoming Network Response ⬇️:
------------------------------------------
StatusCode: 200
Headers:
    - Content-Type: application/json; charset=utf-8
    - Connection: keep-alive
    - Date: Sun, 11 Feb 2024 09:48:46 GMT
    - Keep-Alive: timeout=5
    - Content-Length: 5

Body:
token

Usage Example: Token API

The HTTPClient protocol is a suitable abstraction for making features agnostic of infrastructure details. Decorators allow for the dynamic addition of functionality without having to adapt the underlying implementation. Let's examine a usage example to see how these components interact.

Consider a simple REST API (TokenAPI) that enables users to retrieve and update a token. While the token endpoint does not require a request body, the update endpoint expects the new token to be stored on the server.

enum TokenAPI: HTTPRequest {
    case token
    case update(token: String)
    
    var method: HTTPMethod {
        switch self {
        case .token:
            return .get
        case .update:
            return .post
        }
    }
    
    var path: HTTPPath {
        return "token"
    }
    
    var version: HTTPVersion {
        return "v1"
    }
    
    var body: Data? {
        switch self {
        case let .update(token):
            return token.data(using: .utf8)
        default:
            return nil
        }
    }
}

Additionally, we define the TokenMapper to translate the received data and HTTPResponse into a token. Please note that the mapping may fail if invalid data is received.

enum TokenMapper {
    enum Error: Swift.Error {
        case invalidData
    }

    static func map(data: Data, response: HTTPResponse) throws -> String {
        guard
            response.isSuccess,
            let token = String(data: data, encoding: .utf8)
        else {
            throw Error.invalidData
        }
        
        return token
    }
}

Next, we instantiate a concrete HTTPClient to perform the request. In this case, we decided to use Apple's URLSession by using the URLSessionClient with an ephemeral session and a request timeout interval of 30 seconds.

enum HTTPClientFactory {
    static func make(_ baseUrl: String) -> HTTPClient {
        let baseURL: URL = URL(string: baseUrl)!
        let configuration = URLSessionConfiguration.ephemeral
        configuration.timeoutIntervalForRequest = 30.0
        let session: URLSession = URLSession(configuration: configuration)
        return URLSessionClient(baseURL: baseURL, session: session)
    }
}

By utilizing the extensions, we can include logging and authentication, as well as the necessary headers to execute the request. It is important to note that the order in which the extensions are applied is significant, as it determines the sequence in which the decorations are applied.

enum HTTPClientFactory {
    static func make(_ baseUrl: String) -> HTTPClient {
        let baseURL: URL = URL(string: baseUrl)!
        let configuration = URLSessionConfiguration.ephemeral
        configuration.timeoutIntervalForRequest = 30.0
        let session: URLSession = URLSession(configuration: configuration)
        return URLSessionClient(baseURL: baseURL, session: session)
            .loggingRequestAndResponse()
            .injecting(
                headers: [
                    "header1": "value1",
                    "header2": "value2",
                    "header3": "value3"
                ]
            )
            .authenticated(
                accessToken: {
                    try await Task.sleep(nanoseconds: 1_000_000)
                    return "Bearer <Token>"
                }
            )
    }
}

The ContentView requires a token to be retrieved through the loadToken closure provided in its initializer. This approach ensures that the UI is not dependent on the specific HTTPClient used to execute the request. By using SwiftUI's task modifier, the request is automatically made when the content is loaded on screen.

struct ContentView: View {
    @State private var token: String?
    
    private let loadToken: () async throws -> String
    
    init(loadToken: @escaping () async throws -> String) {
        self.loadToken = loadToken
    }

    var body: some View {
        NavigationStack {
            Text(token ?? "")
                .navigationTitle("Networking")
                .task { token = try? await loadToken() }
        }
    }
}

The UI and httpClient are connected in the composition root. This layer translates the view's request and passes it to the httpClient. The response is then mapped using the TokenMapper to match the signature of the loadToken method.

@main
struct NetworkingApp: App {
    private let httpClient: HTTPClient = HTTPClientFactory.make("http://localhost:3000/")

    var body: some Scene {
        WindowGroup {
            ContentView(loadToken: httpClient.loadToken)
        }
    }
}

private extension HTTPClient {
    func loadToken() async throws -> String {
        let (data, response) = try await self.load(request: TokenAPI.token)
        return try TokenMapper.map(data: data, response: response)
    }
}

Conclusion

In this article, we introduced a lightweight approach to dealing with cross-cutting concerns like networking in modular iOS applications. By following the principles outlined here, developers can keep features agnostic of infrastructure details and benefit from a faster development cycle and the scalability of their modular iOS applications.

References:

Happy Coding 🚀