Networking is the backbone of almost every modern iOS application. However, as projects grow in complexity, the network layer often becomes a “junk drawer” for URL construction, messy completion handlers, and scattered error logic. This tight coupling makes unit testing difficult and maintenance a nightmare. In this article, we are going to build a reusable, testable, and type-safe Network Layer from scratch. By leveraging Clean Architecture principles, the power of Swift Generics, and the modern elegance of Async/Await, we will create a solution that separates concerns and scales with your app. reusable, testable, and type-safe Network Layer Clean Architecture Swift Generics Async/Await Whether you are preparing for a technical interview or refactoring a production codebase, this approach provides a solid foundation for any Swift project. 1. Centralizing API Endpoints with Type-Safe Enums One of the first steps in decoupling your network logic is to move away from hardcoded strings scattered throughout your ViewModels or Repositories. Instead, we use a Type-Safe Enum to define our API contract. Type-Safe Enum /// Defines the available API endpoints in the application. /// This acts as a centralized source of truth for all URL construction. enum APIEndPoint { case list case detail(id: String) /// Computed property to generate the full URL for each endpoint. /// In a production environment, you might use URLComponents for safer query parameter handling. var url: URL { switch self { case .list: return URL(string: "https://themealdb.com/api/json/v1/1/filter.php?c=Dessert")! case .detail(id: let id): return URL(string: "https://themealdb.com/api/json/v1/1/lookup.php?i=\(id)")! } } } /// Defines the available API endpoints in the application. /// This acts as a centralized source of truth for all URL construction. enum APIEndPoint { case list case detail(id: String) /// Computed property to generate the full URL for each endpoint. /// In a production environment, you might use URLComponents for safer query parameter handling. var url: URL { switch self { case .list: return URL(string: "https://themealdb.com/api/json/v1/1/filter.php?c=Dessert")! case .detail(id: let id): return URL(string: "https://themealdb.com/api/json/v1/1/lookup.php?i=\(id)")! } } } 2. Moving Beyond Generic Failures In a production-ready network layer, simply knowing that “an error occurred” is rarely enough. To provide a great user experience and simplify debugging, we need to categorize failures. The ServiceErrors enum allows us to transform raw HTTP responses into meaningful, domain-specific types. ServiceErrors /// Custom error types to categorize network and server-side failures. /// This allows the UI or Domain layer to provide specific feedback to the user. enum ServiceErrors: Error { case internalError(_ statusCode: Int)) // Errors related to client-side or logic issues (4xx) case serverError(_ statusCode: Int) // Errors related to server-side issues (5xx)} /// Custom error types to categorize network and server-side failures. /// This allows the UI or Domain layer to provide specific feedback to the user. enum ServiceErrors: Error { case internalError(_ statusCode: Int)) // Errors related to client-side or logic issues (4xx) case serverError(_ statusCode: Int) // Errors related to server-side issues (5xx)} 3. Abstraction through Protocols In Clean Architecture, we aim for the Dependency Inversion Principle: High-level modules (like ViewModels or Use Cases) should not depend on low-level modules (like a concrete NetworkManager). Instead, both should depend on abstractions. Dependency Inversion Principle NetworkManager This is where the NetworkProtocol comes in. It acts as the "contract" for our networking layer. NetworkProtocol /// The blueprint for our Network Layer. /// Using a protocol allows us to mock the network for Unit Testing (Dependency Inversion) .protocol NetworkProtocol { /// Generic fetch method using a raw URL. func fetch<T>(_ url: URL) async throws -> T where T : Decodable /// Generic fetch method using the APIEndPoint abstraction. func fetch<T>(_ endPoint: APIEndPoint) async throws -> T where T : Decodable } extension NetworkProtocol { /// Default implementation to bridge the Endpoint enum with the raw URL fetcher. func fetch<T>(_ endPoint: APIEndPoint) async throws -> T where T : Decodable { try await fetch(endPoint.url) } } /// The blueprint for our Network Layer. /// Using a protocol allows us to mock the network for Unit Testing (Dependency Inversion) .protocol NetworkProtocol { /// Generic fetch method using a raw URL. func fetch<T>(_ url: URL) async throws -> T where T : Decodable /// Generic fetch method using the APIEndPoint abstraction. func fetch<T>(_ endPoint: APIEndPoint) async throws -> T where T : Decodable } extension NetworkProtocol { /// Default implementation to bridge the Endpoint enum with the raw URL fetcher. func fetch<T>(_ endPoint: APIEndPoint) async throws -> T where T : Decodable { try await fetch(endPoint.url) } } Power of Generics and Type-Safety The use of <T> where T: Decodable makes our network layer incredibly flexible. Instead of writing a separate fetch method for every single model (e.g., fetchDesserts, fetchDetails), we write one generic method. As long as your data model conforms to Decodable, this protocol can handle it, ensuring type safety throughout the entire app. <T> T: Decodable fetchDesserts fetchDetails Decodable Modern Concurrency with async/await async/await By defining our methods as async throws, we embrace the modern Swift concurrency model. This provides several benefits over traditional completion handlers: async throws Readability: The code reads linearly, making it much easier to follow. Safety: It eliminates common bugs associated with forgetting to call a completion block or accidentally calling it twice. Error Propagation: Errors are thrown naturally, allowing the caller to use do-catch blocks for clean error handling. Readability: The code reads linearly, making it much easier to follow. Readability: Safety: It eliminates common bugs associated with forgetting to call a completion block or accidentally calling it twice. Safety: Error Propagation: Errors are thrown naturally, allowing the caller to use do-catch blocks for clean error handling. Error Propagation: do-catch Protocol Extensions: Adding Convenience The protocol extension provides a default implementation for fetching via our APIEndPoint enum. This is a powerful pattern because: default implementation APIEndPoint DRY (Don’t Repeat Yourself): We only have to implement the raw URL logic once in the concrete class. Separation of Concerns: The protocol extension handles the transformation from an abstraction (APIEndPoint) to a concrete requirement (URL), keeping our main NetworkManager implementation clean and focused purely on the network request. DRY (Don’t Repeat Yourself): We only have to implement the raw URL logic once in the concrete class. DRY (Don’t Repeat Yourself): URL Separation of Concerns: The protocol extension handles the transformation from an abstraction (APIEndPoint) to a concrete requirement (URL), keeping our main NetworkManager implementation clean and focused purely on the network request. Separation of Concerns: APIEndPoint URL NetworkManager 4. The Engine: Implementing the NetworkManager Now that we have defined our endpoints, errors, and blueprint, it’s time to build the concrete implementation: the NetworkManager. This class is responsible for the "heavy lifting"—communicating with the internet and transforming raw bytes into Swift objects. NetworkManager /// The concrete implementation of the NetworkProtocol. /// This class handles the actual data task and JSON decoding. final class NetworkManager: NetworkProtocol { private let urlSession: URLSession private let decoder = JSONDecoder() /// Initializer with Dependency Injection. /// By injecting URLSession, we can easily swap it with a MockSession during testing. init(urlSession: URLSession = .shared) { self.urlSession = urlSession } /// Fetches data from a given URL, validates the HTTP response, and decodes the JSON. /// - Parameter url: The destination URL. /// - Returns: A decoded object of type T. func fetch<T>(_ url: URL) async throws -> T where T : Decodable { // Perform the network request using Swift Concurrency (async/await) let (data, response) = try await urlSession.data(from: url) // Ensure the response is an HTTPURLResponse and the status code is within the 200-299 range. guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else { // Cast to HTTPURLResponse to extract the status code for error handling. let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 switch statusCode { case 400...499: throw ServiceErrors.internalError(statusCode) default: throw ServiceErrors.serverError(statusCode) } } // Decode the data into the requested generic type. // Throws an error if the JSON mapping fails. let result = try decoder.decode(T.self, from: data) return result }} /// The concrete implementation of the NetworkProtocol. /// This class handles the actual data task and JSON decoding. final class NetworkManager: NetworkProtocol { private let urlSession: URLSession private let decoder = JSONDecoder() /// Initializer with Dependency Injection. /// By injecting URLSession, we can easily swap it with a MockSession during testing. init(urlSession: URLSession = .shared) { self.urlSession = urlSession } /// Fetches data from a given URL, validates the HTTP response, and decodes the JSON. /// - Parameter url: The destination URL. /// - Returns: A decoded object of type T. func fetch<T>(_ url: URL) async throws -> T where T : Decodable { // Perform the network request using Swift Concurrency (async/await) let (data, response) = try await urlSession.data(from: url) // Ensure the response is an HTTPURLResponse and the status code is within the 200-299 range. guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else { // Cast to HTTPURLResponse to extract the status code for error handling. let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 switch statusCode { case 400...499: throw ServiceErrors.internalError(statusCode) default: throw ServiceErrors.serverError(statusCode) } } // Decode the data into the requested generic type. // Throws an error if the JSON mapping fails. let result = try decoder.decode(T.self, from: data) return result }} Why a final class? final class We mark the NetworkManager as final to prevent subclassing. In a Clean Architecture setup, this class should be a specialized tool that does one thing well. Disabling inheritance improves compilation time and ensures the internal logic remains protected and immutable. NetworkManager final Dependency Injection for Testability The initializer takes a URLSession as a parameter, defaulting to .shared. This is a crucial detail for Unit Testing. URLSession .shared Unit Testing In production, the app uses the standard shared session. In tests, you can inject a URLSession with a mocked configuration to return local data without ever hitting a real server. This keeps your tests fast, reliable, and offline-capable. In production, the app uses the standard shared session. In tests, you can inject a URLSession with a mocked configuration to return local data without ever hitting a real server. This keeps your tests fast, reliable, and offline-capable. URLSession The Modern Request Lifecycle The fetch method implementation demonstrates the elegance of Swift Concurrency: fetch Swift Concurrency Data Fetching: We use urlSession.data(from: url), which suspends the function without blocking the main thread. Validation: Before we even look at the data, we validate the HTTPURLResponse. We use the pattern matching operator (200..<300 ~= statusCode) to ensure the request was successful. Detailed Error Mapping: If the request fails, we switch through the status code. This allows us to throw our custom ServiceErrors, providing clear context to the upper layers of the architecture (like the ViewModel). Decoding: Finally, we use JSONDecoder to map the raw data into our generic type T. Because we used Generics, this single line of code can handle a User model, a List of meals, or a Product detail model. Data Fetching: We use urlSession.data(from: url), which suspends the function without blocking the main thread. Data Fetching: urlSession.data(from: url) Validation: Before we even look at the data, we validate the HTTPURLResponse. We use the pattern matching operator (200..<300 ~= statusCode) to ensure the request was successful. Validation: HTTPURLResponse 200..<300 ~= statusCode Detailed Error Mapping: If the request fails, we switch through the status code. This allows us to throw our custom ServiceErrors, providing clear context to the upper layers of the architecture (like the ViewModel). Detailed Error Mapping: ServiceErrors Decoding: Finally, we use JSONDecoder to map the raw data into our generic type T. Because we used Generics, this single line of code can handle a User model, a List of meals, or a Product detail model. Decoding: JSONDecoder T User List Product Separation of Concerns Notice that NetworkManager doesn't know what data it is fetching; it only knows how to fetch and decode it. By keeping the decoding logic here, we ensure that the rest of the app only ever deals with clean, strongly-typed Swift models, never raw JSON. NetworkManager what how 5.Bridging Data and Business Logic While the NetworkManager handles the technicalities of HTTP and JSON, we need a specialized object to handle the Domain Logic. This is where MealManager comes in. It serves as a Repository that provides the rest of the application with clean, high-level methods to access data. NetworkManager Domain Logic MealManager final class MealManager { private let networkManager: NetworkProtocol init(networkManager: NetworkProtocol) { self.networkManager = networkManager } // Fetches a list of meals from a network source. func fetchMeals() async throws -> [Meal] { // The response is expected to conform to MealsResponse<[Meal]>, a generic type expecting an array of Meal. let response: MealsResponse<[Meal]> = try await networkManager.fetch(.list) return response.meals } // Fetch meal detail func fetchMealDetail(id: Meal.ID) async throws -> MealDetail? { // The response is expected to conform to MealsResponse<[MealDetail]>, which is a generic type expecting an array of MealDetail. let response: MealsResponse<[MealDetail]> = try await networkManager.fetch(.detail(id: id)) return response.meals.first }} final class MealManager { private let networkManager: NetworkProtocol init(networkManager: NetworkProtocol) { self.networkManager = networkManager } // Fetches a list of meals from a network source. func fetchMeals() async throws -> [Meal] { // The response is expected to conform to MealsResponse<[Meal]>, a generic type expecting an array of Meal. let response: MealsResponse<[Meal]> = try await networkManager.fetch(.list) return response.meals } // Fetch meal detail func fetchMealDetail(id: Meal.ID) async throws -> MealDetail? { // The response is expected to conform to MealsResponse<[MealDetail]>, which is a generic type expecting an array of MealDetail. let response: MealsResponse<[MealDetail]> = try await networkManager.fetch(.detail(id: id)) return response.meals.first }} Real-World Dependency Inversion Notice that MealManager does not know NetworkManager exists. Instead, it only knows about NetworkProtocol. MealManager NetworkManager NetworkProtocol The Benefit: If you ever decide to replace URLSession with another framework (like Alamofire) or a local database (like Core Data), you only need to create a new class conforming to NetworkProtocol. You won't have to change a single line of code inside MealManager. The Benefit: If you ever decide to replace URLSession with another framework (like Alamofire) or a local database (like Core Data), you only need to create a new class conforming to NetworkProtocol. You won't have to change a single line of code inside MealManager. The Benefit: URLSession NetworkProtocol MealManager Simplifying the Data Flow APIs often wrap their data in “envelope” objects (like your MealsResponse). The UI layer doesn't care about these wrappers; it only wants the list of meals. MealManager handles this "unwrapping" logic: MealsResponse MealManager In fetchMeals(), it converts the network response into a clean [Meal] array. In fetchMealDetail(), it handles the logic of extracting the first item from a detail array, returning an optional MealDetail?. In fetchMeals(), it converts the network response into a clean [Meal] array. fetchMeals() [Meal] In fetchMealDetail(), it handles the logic of extracting the first item from a detail array, returning an optional MealDetail?. fetchMealDetail() MealDetail? This ensures that your ViewModels stay lean. They don’t have to deal with indexing arrays or decoding envelopes — they just ask the manager for the data they need. ViewModels stay lean 6. Connecting Logic to the UI The MainViewModel acts as the conductor of our data flow. It takes the information provided by the MealManager and prepares it for the SwiftUI views to display. MainViewModel MealManager protocol GetMealsProtocol { func fetchMeals() async @MainActorclass MainViewModel: ObservableObject, GetMealsProtocol { @Published var meals: [Meal] = [] private let manager: MealManager private var errorMessage: String = "" init(manager: MealManager) { self.manager = manager } func fetchMeals() async { do { let meals = try await manager.fetchMeals() try Task.checkCancellation() self.meals = meals } catch { self.errorMessage = error.localizedDescription } } } protocol GetMealsProtocol { func fetchMeals() async @MainActorclass MainViewModel: ObservableObject, GetMealsProtocol { @Published var meals: [Meal] = [] private let manager: MealManager private var errorMessage: String = "" init(manager: MealManager) { self.manager = manager } func fetchMeals() async { do { let meals = try await manager.fetchMeals() try Task.checkCancellation() self.meals = meals } catch { self.errorMessage = error.localizedDescription } } } Why Use a Protocol for the ViewModel? You might notice the GetMealsProtocol. While many developers skip this step, abstracting your ViewModel behind a protocol is a hallmark of Clean Architecture. It allows you to: GetMealsProtocol Clean Architecture Mock the View State: You can create a “MockViewModel” that returns hardcoded data to preview your SwiftUI views in different states (loading, success, error) without running the full app logic. Decouple the View: The SwiftUI View doesn’t need to know about the concrete class; it only needs an object that can “fetch meals.” Mock the View State: You can create a “MockViewModel” that returns hardcoded data to preview your SwiftUI views in different states (loading, success, error) without running the full app logic. Mock the View State: Decouple the View: The SwiftUI View doesn’t need to know about the concrete class; it only needs an object that can “fetch meals.” Decouple the View: Thread Safety with @MainActor @MainActor In the world of async/await, it is easy to accidentally update the UI from a background thread. By marking the class with @MainActor, we guarantee that all updates to our @Published properties (like meals) happen on the Main Thread. This eliminates common crashes and "purple warnings" in Xcode related to threading. async/await @MainActor @Published meals Main Thread Robust Task Management The fetchMeals method demonstrates a production-standard way to handle asynchronous tasks: fetchMeals Do-Catch Block: We gracefully handle any errors thrown by the network layer. Instead of the app crashing, we capture the error to show a user-friendly message. Task Cancellation: The line try Task.checkCancellation() is a professional touch. In SwiftUI, if a user navigates away from a screen before the network request finishes, the task is cancelled. Checking for cancellation ensures we don't spend CPU cycles processing data for a screen that is no longer visible. State Management: Once the data is successfully fetched, we update the meals property. Because it is marked as @Published, SwiftUI will automatically re-render the UI to show the new list. Do-Catch Block: We gracefully handle any errors thrown by the network layer. Instead of the app crashing, we capture the error to show a user-friendly message. Do-Catch Block: Task Cancellation: The line try Task.checkCancellation() is a professional touch. In SwiftUI, if a user navigates away from a screen before the network request finishes, the task is cancelled. Checking for cancellation ensures we don't spend CPU cycles processing data for a screen that is no longer visible. Task Cancellation: try Task.checkCancellation() State Management: Once the data is successfully fetched, we update the meals property. Because it is marked as @Published, SwiftUI will automatically re-render the UI to show the new list. State Management: meals @Published Conclusion: Scaling with Confidence Building a network layer isn’t just about making an API call; it’s about creating a foundation that your entire team can trust. By moving away from “stringly-typed” URLs and messy completion handlers, we’ve built a system that is resilient to change. resilient to change. Through the lens of Clean Architecture, we achieved several key goals: Clean Architecture Separation of Concerns: Our NetworkManager handles the "how," our MealManager handles the "what," and our ViewModel handles the "when." Testability: By depending on protocols rather than concrete classes, we can swap real network calls for mocks in seconds, making our unit tests fast and reliable. Safety: Using async/await and @MainActor ensures that our code is modern, readable, and thread-safe by design. Separation of Concerns: Our NetworkManager handles the "how," our MealManager handles the "what," and our ViewModel handles the "when." Separation of Concerns: NetworkManager MealManager ViewModel Testability: By depending on protocols rather than concrete classes, we can swap real network calls for mocks in seconds, making our unit tests fast and reliable. Testability: Safety: Using async/await and @MainActor ensures that our code is modern, readable, and thread-safe by design. Safety: async/await @MainActor The beauty of this architecture is its flexibility. Whether you are adding new features, implementing an offline cache, or migrating to a new API version, your codebase is now prepared to handle it with minimal friction. Explore the Full Source Code Architecture is best understood by seeing how all the moving parts work together in a real environment. I have prepared a complete, production-ready example on GitHub. You can explore the implementation of the protocols, the generic response handling, and the SwiftUI integration here: