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
Head of Development & Engineering
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
APIClientfor 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
NetworkLoggerwith request/response duration tracking - Testable endpoints via
MockAPIClientandXCTest - 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.