Delhi | 25°C (windy)

Crafting a Rock-Solid Networking Layer in Swift: From Chaos to Clean APIClient (Part 1)

  • Nishadil
  • September 03, 2025
  • 0 Comments
  • 6 minutes read
  • 6 Views
Crafting a Rock-Solid Networking Layer in Swift: From Chaos to Clean APIClient (Part 1)

In the world of iOS development, networking is the backbone of almost every application. From fetching user data to submitting forms, your app constantly interacts with remote servers. While Apple provides robust tools like `URLSession` for these tasks, the way you structure your networking code can dramatically impact your project's maintainability, scalability, and testability.

Many developers, especially when starting out or under tight deadlines, might resort to sprinkling `URLSession.shared.dataTask` calls throughout their view controllers or business logic. This approach, while functional in the short term, quickly leads to a tangled mess of duplicated code, inconsistent error handling, and a nightmare for testing. Imagine needing to change an API endpoint – you'd be hunting through dozens of files!

This article, the first in a series, will guide you through the initial steps of building a clean, scalable, and delightful networking layer in Swift. Our journey begins by understanding the limitations of ad-hoc network requests and then introduces a structured approach using an `APIClient` and a clear definition of API endpoints, focusing specifically on GET requests.

The Problem with Ad-Hoc Networking

Let's face it: writing `URLSession` code directly every time you need to make a request can be repetitive. You're constantly constructing URLs, setting HTTP methods, adding headers, and decoding responses. This boilerplate not only clutters your code but also makes it hard to manage. When `URLSession` calls are scattered across different parts of your application, it becomes difficult to:

  • **Maintain consistency:** Different developers might handle errors or parse data in slightly different ways.
  • **Implement caching or retry mechanisms:** These features would need to be replicated everywhere.
  • **Test effectively:** Mocking `URLSession` directly for every network call is cumbersome and fragile.
  • **Scale the application:** As your app grows and interacts with more endpoints, the complexity spirals out of control.

The solution lies in abstraction and centralization. We need a dedicated layer that handles the intricacies of network communication, freeing our application logic to focus solely on what it does best.

Introducing the `Endpoint` Protocol/Enum

The first step towards a clean networking layer is to clearly define what an API request entails. Instead of constructing URLs on the fly, let's create a blueprint for our requests. An `Endpoint` can be a protocol or an enum that encapsulates all the necessary information for a network call.

Consider an `Endpoint` protocol:

protocol Endpoint {
    var baseURL: URL { get }
    var path: String { get }
    var method: HTTPMethod { get }
    var headers: [String: String]? { get }
    var parameters: [String: Any]? { get }
}

And an `HTTPMethod` enum:

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

Now, let's define our actual API endpoints using an enum that conforms to this `Endpoint` protocol. For example, fetching a list of users or details of a specific user:

enum API {
    case getUsers
    case getUser(id: Int)
}

extension API: Endpoint {
    var baseURL: URL { return URL(string: "https://api.yourapp.com")! }

    var path: String {
        switch self {
        case .getUsers:
            return "/users"
        case .getUser(let id):
            return "/users/\(id)"
        }
    }

    var method: HTTPMethod {
        return .get // For now, all our examples are GET
    }

    var headers: [String: String]? {
        // Example: Add an authorization header
        return ["Content-Type": "application/json", "Authorization": "Bearer YOUR_TOKEN"]
    }

    var parameters: [String: Any]? {
        switch self {
        case .getUsers, .getUser(_):
            return nil // GET requests typically have parameters in the URL path or query string
        }
    }
}

This structure immediately brings clarity. All your API requests are defined in one place, making them easy to find, modify, and understand.

Building the `APIClient`

With our `Endpoint` definitions in place, the next step is to create a central `APIClient` that takes an `Endpoint` and executes the corresponding `URLSession` request. This client will encapsulate the actual networking logic, error handling, and JSON decoding.

enum NetworkError: Error {
    case invalidURL
    case noData
    case decodingError(Error)
    case serverError(statusCode: Int, data: Data?)
    case unknownError(Error?)
}

class APIClient {
    private let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func request<T: Decodable>(endpoint: Endpoint, completion: @escaping (Result<T, NetworkError>) -> Void) {
        guard var urlComponents = URLComponents(url: endpoint.baseURL.appendingPathComponent(endpoint.path), resolvingAgainstBaseURL: true) else {
            completion(.failure(.invalidURL))
            return
        }

        if let parameters = endpoint.parameters, endpoint.method == .get {
            urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
        }

        guard let url = urlComponents.url else {
            completion(.failure(.invalidURL))
            return
        }

        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        endpoint.headers?.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }

        // For simplicity, we'll only handle GET query parameters for now.
        // POST/PUT body handling will be covered in subsequent parts.

        session.dataTask(with: request) { data, response, error in
            if let error = error {
                completion(.failure(.unknownError(error)))
                return
            }

            guard let httpResponse = response as? HTTPURLResponse else {
                completion(.failure(.unknownError(nil)))
                return
            }

            guard (200...299).contains(httpResponse.statusCode) else {
                completion(.failure(.serverError(statusCode: httpResponse.statusCode, data: data)))
                return
            }

            guard let data = data else {
                completion(.failure(.noData))
                return
            }

            do {
                let decodedObject = try JSONDecoder().decode(T.self, from: data)
                completion(.success(decodedObject))
            } catch {
                completion(.failure(.decodingError(error)))
            }
        }.resume()
    }
}

In this `APIClient`:

  • It takes an `Endpoint` and a `completion` handler.
  • It constructs the `URLRequest` based on the `Endpoint`'s properties.
  • It uses `URLSession.shared.dataTask` to perform the request.
  • It handles basic error cases: `invalidURL`, `noData`, `serverError`, `decodingError`, and `unknownError`.
  • It attempts to decode the successful response into a `Decodable` type `T`.

Putting It All Together: Making a GET Request

Now, let's see how easy it is to use our new `APIClient` to fetch data.

struct User: Decodable, Identifiable {
    let id: Int
    let name: String
    let email: String
}

let apiClient = APIClient()

// Fetch all users
apiClient.request(endpoint: API.getUsers) { (result: Result<[User], NetworkError>) in
    switch result {
    case .success(let users):
        print("Fetched users: \(users)")
    case .failure(let error):
        print("Error fetching users: \(error)")
    }
}

// Fetch a specific user
apiClient.request(endpoint: API.getUser(id: 123)) { (result: Result<User, NetworkError>) in
    switch result {
    case .success(let user):
        print("Fetched user: \(user)")
    case .failure(let error):
        print("Error fetching user: \(error)")
    }
}

This code is significantly cleaner and more readable. Your view controllers or business logic no longer need to know the specifics of `URLSession`, URL construction, or JSON decoding. They simply ask the `APIClient` to fetch data using a well-defined `Endpoint`.

What's Next?

This first part laid the foundational groundwork for a robust networking layer. We've moved from scattered `URLSession` calls to a structured approach with `Endpoint` definitions and a centralized `APIClient` for GET requests. However, there's still much more to explore:

  • Handling POST, PUT, and DELETE requests with request bodies.
  • Implementing robust error handling and custom error types.
  • Adding authentication (e.g., OAuth tokens).
  • Introducing request retries and caching strategies.
  • Making the client testable and mockable.
  • Integrating with async/await.

Stay tuned for the next installment, where we'll delve deeper into handling various HTTP methods and enhancing our `APIClient` even further. By the end of this series, you'll have a fully functional, highly maintainable, and testable networking layer for your Swift applications.

Disclaimer: This article was generated in part using artificial intelligence and may contain errors or omissions. The content is provided for informational purposes only and does not constitute professional advice. We makes no representations or warranties regarding its accuracy, completeness, or reliability. Readers are advised to verify the information independently before relying on