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.
First, let's take a look at the naive approach, where multiple independent features directly depend on the networking package.
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.
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.
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.
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.
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.
Let's take a moment to highlight four decorations of the HTTPClient
to deal with authentication, error handling, header injection as well as logging
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.
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()
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"
]
)
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
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)
}
}
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.
Happy Coding 🚀