AG
Writing
Engineering8 min read

An Enterprise-Ready Combine Networking Layer in SwiftUI

A modular, event-driven networking architecture for SwiftUI — typed responses, environment switching, structured errors, logging, and testable services — built with async/await evolution in mind.

Charith 'Alex' Gunasekara

Charith 'Alex' Gunasekara

Head of Development & Engineering

Originally published on Medium
SwiftUICombineiOSArchitectureNetworking

As a SwiftUI project scales, API communication is where the mess usually starts — duplicated request code, scattered error handling, no clear place for logging or environment config. This is a modular, event-driven networking layer built on Combine that removes the boilerplate and keeps concerns cleanly separated, while staying easy to migrate to async/await later.

What it gives you:

  • A centralized APIClient for all requests
  • Environment-based URL switching (DEV, QA, UAT, PROD)
  • Type-safe ServerResponse<T> decoding
  • Consistent error mapping
  • Scalable, per-module service APIs
  • Reactive Combine workflows (.flatMap, .zip, .debounce)
  • A NetworkLogger with request/response duration tracking
  • Testable endpoints via MockAPIClient and XCTest
  • A clean migration path to async/await

APIClient: the core request engine

A shared singleton wrapping a configured URLSession, exposed behind a protocol so it can be mocked.

import Combine
import Foundation
 
class APIClient: APIClientProtocol {
    static let shared = APIClient()
    private let session: URLSession
 
    private init() {
        let configuration = URLSessionConfiguration.default
        configuration.timeoutIntervalForRequest = 30
        configuration.timeoutIntervalForResource = 60
        session = URLSession(configuration: configuration)
    }
 
    func sendRequest<T: Codable>(_ request: URLRequest) -> AnyPublisher<ServerResponse<T>, APIError> {
        NetworkLogger.logRequest(request)
 
        return session.dataTaskPublisher(for: request)
            .handleEvents(receiveOutput: { output in
                NetworkLogger.logResponse(output.response, data: output.data)
            }, receiveCompletion: { completion in
                if case .failure(let error) = completion {
                    NetworkLogger.logError(error)
                }
            })
            .tryMap { result -> Data in
                guard let response = result.response as? HTTPURLResponse else {
                    throw APIError.unknown
                }
 
                if response.statusCode == 401 {
                    throw APIError.unauthorized
                }
 
                guard (200...299).contains(response.statusCode) else {
                    throw APIError.requestFailed
                }
                guard !result.data.isEmpty else {
                    throw APIError.emptyResponse
                }
                return result.data
            }
            .decode(type: ServerResponse<T>.self, decoder: JSONDecoder())
            .mapError { error in
                if let decodingError = error as? DecodingError {
                    return .decodingError(decodingError)
                } else if let apiError = error as? APIError {
                    return apiError
                } else if let _ = error as? URLError {
                    return .requestFailed
                } else {
                    return .unknown
                }
            }
            .eraseToAnyPublisher()
    }
}

A few things worth calling out: handleEvents taps the stream purely for logging without touching the data flow; tryMap turns raw HTTP status codes into meaningful APIErrors (401 gets its own branch); and mapError collapses every failure path into one typed enum so callers handle one error type, not five.

APIClientProtocol: abstraction and testability

protocol APIClientProtocol {
    func sendRequest<T: Codable>(_ request: URLRequest) -> AnyPublisher<ServerResponse<T>, APIError>
}

This single protocol buys testability (inject a MockAPIClient — no real server), decoupling (services depend on the abstraction, not the concrete client), and flexibility (swap in caching or retry strategies behind the same interface). Services declare the dependency and default to the shared instance:

init(apiClient: APIClientProtocol = APIClient.shared) {
    self.apiClient = apiClient
}

APIEnvironment: switch targets in one line

enum APIEnvironment: String {
    case dev
    case qa
    case uat
    case prod
 
    var baseURL: String {
        switch self {
        case .dev:  return "[DEV Base URL]"
        case .qa:   return "[QA Base URL]"
        case .uat:  return "[UAT Base URL]"
        case .prod: return "[Production Base URL]"
        }
    }
}
 
struct NetworkConstants {
    static let environment: APIEnvironment = .dev
}

Change one constant and the whole app re-targets.

URLRequestBuilder: clean, reusable request construction

import Foundation
 
enum HTTPMethod: String {
    case GET
    case POST
    case PUT
    case DELETE
}
 
struct URLRequestBuilder {
    static func buildRequest(
        endpoint: String,
        method: HTTPMethod = .POST,
        body: Data? = nil,
        requiresAuth: Bool = true
    ) throws -> URLRequest {
        guard let url = URL(string: NetworkConstants.environment.baseURL + endpoint) else {
            throw APIError.invalidURL
        }
 
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
 
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue("anyValue", forHTTPHeaderField: "anyField")
 
        if requiresAuth, let token = UserDefaults.standard.value(forKey: "accessToken") as? String {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
 
        switch method {
        case .POST, .PUT:
            request.httpBody = body
        case .GET, .DELETE:
            request.httpBody = nil
        }
 
        return request
    }
}

The builder composes the URL from the active environment, sets standard headers, attaches the auth token only when required, and only assigns a body to methods that support one.

ServerResponse: one generic wrapper for everything

Most APIs return a consistent envelope:

{
  "success": true,
  "data": { },
  "message": "Optional message"
}

So model it once, generically:

struct ServerResponse<T: Codable>: Codable {
    let data: T?
    let message: String
    let success: Bool
 
    enum CodingKeys: String, CodingKey {
        case data
        case message
        case success
    }
}

APIError and ErrorMapper: structured errors

One enum captures every failure mode; LocalizedError gives readable descriptions; Equatable makes it trivial to assert on in tests and retry logic.

enum APIError: Error, LocalizedError, Equatable {
    case invalidURL
    case requestFailed
    case decodingError(DecodingError)
    case serverError(message: String)
    case unauthorized
    case unknown
    case emptyResponse
    case encodingError
 
    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "The URL provided was invalid."
        case .requestFailed:
            return "The network request failed."
        case .decodingError(let decodingError):
            return "Failed to decode the server response: \(decodingError.localizedDescription)"
        case .serverError(let message):
            return message
        case .unauthorized:
            return "You are not authorized. Please sign in again."
        case .emptyResponse:
            return "The server returned an empty response."
        case .encodingError:
            return "Failed to encode the request body."
        case .unknown:
            return "An unknown error occurred."
        }
    }
}

The ErrorMapper translates technical errors into messages a user should actually see:

struct ErrorMapper {
    static func message(for error: APIError) -> String {
        switch error {
        case .unauthorized:
            return "Session expired. Please login again."
        case .requestFailed:
            return "Unable to connect to server. Please try again."
        case .decodingError:
            return "Something went wrong while reading the server response."
        case .serverError(let message):
            return message
        case .emptyResponse:
            return "No data was returned. Please try again."
        case .invalidURL, .encodingError, .unknown:
            return "Something went wrong. Please try again."
        }
    }
}

NetworkLogger: precise logging, non-PROD only

A centralized logger that prints structured request/response/error output — including per-request duration — and silently disables itself in production.

struct NetworkLogger {
    private static var requestStartTimes: [URL: Date] = [:]
 
    static func logRequest(_ request: URLRequest) {
        guard NetworkConstants.environment != .prod else { return }
 
        if let url = request.url {
            requestStartTimes[url] = Date()
        }
 
        print("\n----- 📤 OUTGOING REQUEST -----")
 
        if let method = request.httpMethod,
           let url = request.url {
            print("URL: \(url.absoluteString)")
            print("Method: \(method)")
        }
 
        if let headers = request.allHTTPHeaderFields {
            print("Headers: \(headers)")
        }
 
        if let body = request.httpBody,
           let bodyString = String(data: body, encoding: .utf8) {
            print("Body: \(bodyString)")
        }
 
        print("------------------------------\n")
    }
 
    static func logResponse(_ response: URLResponse?, data: Data?) {
        guard NetworkConstants.environment != .prod else { return }
 
        print("\n----- 📥 INCOMING RESPONSE -----")
 
        if let httpResponse = response as? HTTPURLResponse {
            print("Status Code: \(httpResponse.statusCode)")
            print("URL: \(httpResponse.url?.absoluteString ?? "Unknown URL")")
            print("Headers: \(httpResponse.allHeaderFields)")
 
            if let url = httpResponse.url, let startTime = requestStartTimes[url] {
                let duration = Date().timeIntervalSince(startTime)
                print("Duration: \(String(format: "%.3f", duration)) seconds")
                requestStartTimes.removeValue(forKey: url)
            }
        }
 
        if let data = data,
           let bodyString = String(data: data, encoding: .utf8) {
            print("Body: \(bodyString)")
        }
 
        print("------------------------------\n")
    }
 
    static func logError(_ error: Error) {
        guard NetworkConstants.environment != .prod else { return }
 
        print("\n----- ❌ ERROR -----")
        print("Error: \(error.localizedDescription)")
        print("--------------------\n")
    }
}

Services: your gateway to the API

Each module gets a small service behind a protocol, depending on the abstract client:

protocol AuthServiceProtocol {
    func signIn(email: String, password: String) -> AnyPublisher<ServerResponse<SignInResponse>, APIError>
}
 
class AuthService: AuthServiceProtocol {
    private let apiClient: APIClientProtocol
 
    init(apiClient: APIClientProtocol = APIClient.shared) {
        self.apiClient = apiClient
    }
 
    func signIn(email: String, password: String) -> AnyPublisher<ServerResponse<SignInResponse>, APIError> {
        let requestBody = SignInRequest(username: email, password: password)
 
        guard let bodyData = try? JSONEncoder().encode(requestBody) else {
            return Fail(error: APIError.encodingError).eraseToAnyPublisher()
        }
 
        do {
            let request = try URLRequestBuilder.buildRequest(
                endpoint: Endpoints.signIn,
                method: .POST,
                body: bodyData
            )
            return self.apiClient.sendRequest(request)
        } catch {
            return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
        }
    }
}

With request/response models kept API-specific and isolated:

struct SignInRequest: Codable {
    let username: String
    let password: String
}
 
struct SignInResponse: Codable {
    let api_token: String
    let user_information: [UserInformation]
 
    struct UserInformation: Codable {
        let user_id: Int
        let first_name: String
        let last_name: String
    }
}

SignInViewModel: the reactive orchestrator

The view model wires services to SwiftUI through Combine. A single call with full state handling:

authService.signIn(email: email, password: password)
    .receive(on: DispatchQueue.main)
    .sink { [weak self] completion in
        self?.isLoading = false
 
        if case let .failure(error) = completion {
            if error == .unauthorized {
                // Handle session expiration or force logout
            } else {
                self?.apiStatus = ErrorMapper.message(for: error)
            }
        }
    } receiveValue: { [weak self] response in
        guard let self = self else { return }
 
        if response.success, let data = response.data {
            // UserDefaults.standard.set(data.api_token, forKey: "accessToken")
            self.apiStatus = "API Request Success"
        } else {
            self.apiStatus = ErrorMapper.message(for: .unknown)
        }
 
        self.isLoading = false
    }
    .store(in: &cancellables)

Because everything returns a publisher, composition is free. Chain requests with .flatMap:

authService.signIn(email: email, password: password)
    .flatMap { _ in
        DashboardService().dashboardData()
    }

Run them in parallel with .zip:

signInPublisher
    .zip(dashboardPublisher)

Or debounce user input so a search fires only when typing stops:

$searchText
    .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
    .removeDuplicates()
    .flatMap { query in searchService.search(query: query) }

Testing with MockAPIClient

Because services depend on APIClientProtocol, you can test the whole flow with no network at all:

class MockAPIClient: APIClientProtocol {
    var mockResponse: Data?
    var mockError: APIError?
 
    func sendRequest<T>(_ request: URLRequest) -> AnyPublisher<ServerResponse<T>, APIError> where T: Codable {
        if let error = mockError {
            return Fail(error: error).eraseToAnyPublisher()
        }
 
        guard let responseData = mockResponse else {
            return Fail(error: .emptyResponse).eraseToAnyPublisher()
        }
 
        do {
            let decoded = try JSONDecoder().decode(ServerResponse<T>.self, from: responseData)
            return Just(decoded)
                .setFailureType(to: APIError.self)
                .eraseToAnyPublisher()
        } catch {
            return Fail(error: .decodingError(error as! DecodingError)).eraseToAnyPublisher()
        }
    }
}
func testSignIn_Success() {
    let mockAPIClient = MockAPIClient()
 
    mockAPIClient.mockResponse = """
    {
        "success": true,
        "message": "",
        "data": {
            "api_token": "test_token_123",
            "user_information": [
                { "user_id": 123, "first_name": "John", "last_name": "Doe" }
            ]
        }
    }
    """.data(using: .utf8)!
 
    let authService = AuthService(apiClient: mockAPIClient)
    let expectation = self.expectation(description: "SignIn Success")
 
    authService.signIn(email: "test@example.com", password: "password123")
        .sink(receiveCompletion: { completion in
            if case let .failure(error) = completion {
                XCTFail("Expected success but got error: \(error.localizedDescription)")
            }
        }, receiveValue: { response in
            XCTAssertTrue(response.success)
            XCTAssertEqual(response.data?.api_token, "test_token_123")
            XCTAssertEqual(response.data?.user_information.first?.first_name, "John")
            expectation.fulfill()
        })
        .store(in: &cancellables)
 
    wait(for: [expectation], timeout: 5.0)
}

What's next

This is a complete, modular, event-driven Combine networking architecture for modern SwiftUI apps — engineered for testability, scalability, and real-world robustness. In a future piece I'll cover migrating it to Swift's async/await model, making the code even cleaner and more readable.

ShareLinkedInX

Keep reading