If you’re working on any iOS app, sooner or later, you’ll face the need to “talk” to a server, at which point it’s very easy to slide into hell: manually building URLRequest, callbacks, duplicated logic, dozens of do-catch blocks, and little guarantee that you won’t forget a header or miss the endpoint. With the arrival of async/await and the new Swift era, we got the ability to write networking code that is: With the arrival of async/await and the new Swift era, we got the ability to write networking code that is: type-safe; scalable without pain; testable; not turning into garbage. type-safe; type-safe; scalable without pain; scalable without pain; testable; testable; not turning into garbage. not turning into garbage. In this series, we’ll build a clean, modular, modern networking layer from scratch. We’ll: In this series, we’ll build a clean, modular, modern networking layer from scratch. We’ll: Configure environments via .xcconfig and Info.plist; Define universal models for requests and responses; Split responsibilities into Requestable, Resource, APIClient; Implement a full error system with APIError, NetworkError, and status codes; Connect the ViewModel with async/await and link it to the UI. Configure environments via .xcconfig and Info.plist; Configure environments via .xcconfig and Info.plist; Define universal models for requests and responses; Define universal models for requests and responses; Split responsibilities into Requestable, Resource, APIClient; Split responsibilities into Requestable, Resource, APIClient; Implement a full error system with APIError, NetworkError, and status codes; Implement a full error system with APIError, NetworkError, and status codes; Connect the ViewModel with async/await and link it to the UI. Connect the ViewModel with async/await and link it to the UI. Everything you’ll see is real production code, not toy demos. You’ll be able to grab it right away and use it in your projects, adapting to any architecture — from MVVM to VIPER and TCA. In this first part, we’ll focus on the most basic implementation, while the real meat starts in the second one :) Now, let’s throw together everything needed for a simple GET request. We’ll prepare the model, the request entity, and the actual client that executes it, with a usage example of course. We’ll cover other request types, error handling, and multiple environments next time. Ready? Let’s go! Let’s go! Project structure Project structure Demo Project ├── App │ ├── Assets │ ├── Info.plist // Values from .xcconfig, e.g. baseURL │ └── NetworkingApp.swift // App entry point, initializes ViewModel │ ├── Configuration │ ├── AppConfiguration.swift // Reading baseURL and other params from Info.plist at runtime │ ├── Production.xcconfig // Production environment configuration │ └── Sandbox.xcconfig // Sandbox / test environment configuration │ ├── Data │ ├── BodyModels.swift // Models we send to the server (e.g., Post) │ ├── Requests.swift // API request definitions (structs implementing Requestable) │ └── Responses.swift // Models returned from the server (e.g., User, Comment) │ ├── Errors │ ├── APIError.swift // Errors returned by the API in response body │ └── NetworkError.swift // Client-side errors: invalid URL, encoding/decoding issues, etc. │ ├── Extensions │ ├── JSONDecoderExt.swift // JSONDecoder extension for snake_case → camelCase │ ├── JSONEncoderExt.swift // JSONEncoder extension for camelCase → snake_case │ └── URLRequestExt.swift // Creating URLRequest from Requestable │ ├── Networking │ ├── APIClient.swift // Universal client sending requests via URLSession │ ├── APIEnvironment.swift // Current environment description: baseURL and common headers │ ├── Requestable.swift // Protocol for declarative request definition │ └── Resource.swift // Combines request and response decoding strategy │ ├── Screens │ ├── ContentView.swift // SwiftUI UI with buttons calling ViewModel methods │ └── ViewModel.swift // ViewModel with async/await, connects UI and networking layer │ └── Type safety ├── HTTPMethod.swift // Enum of HTTP methods (GET, POST, PUT, DELETE) ├── HTTPHeaderKey.swift // Typed header keys (e.g., .contentType) └── HTTPStatusCode.swift // HTTP status code wrapper with handy properties Demo Project ├── App │ ├── Assets │ ├── Info.plist // Values from .xcconfig, e.g. baseURL │ └── NetworkingApp.swift // App entry point, initializes ViewModel │ ├── Configuration │ ├── AppConfiguration.swift // Reading baseURL and other params from Info.plist at runtime │ ├── Production.xcconfig // Production environment configuration │ └── Sandbox.xcconfig // Sandbox / test environment configuration │ ├── Data │ ├── BodyModels.swift // Models we send to the server (e.g., Post) │ ├── Requests.swift // API request definitions (structs implementing Requestable) │ └── Responses.swift // Models returned from the server (e.g., User, Comment) │ ├── Errors │ ├── APIError.swift // Errors returned by the API in response body │ └── NetworkError.swift // Client-side errors: invalid URL, encoding/decoding issues, etc. │ ├── Extensions │ ├── JSONDecoderExt.swift // JSONDecoder extension for snake_case → camelCase │ ├── JSONEncoderExt.swift // JSONEncoder extension for camelCase → snake_case │ └── URLRequestExt.swift // Creating URLRequest from Requestable │ ├── Networking │ ├── APIClient.swift // Universal client sending requests via URLSession │ ├── APIEnvironment.swift // Current environment description: baseURL and common headers │ ├── Requestable.swift // Protocol for declarative request definition │ └── Resource.swift // Combines request and response decoding strategy │ ├── Screens │ ├── ContentView.swift // SwiftUI UI with buttons calling ViewModel methods │ └── ViewModel.swift // ViewModel with async/await, connects UI and networking layer │ └── Type safety ├── HTTPMethod.swift // Enum of HTTP methods (GET, POST, PUT, DELETE) ├── HTTPHeaderKey.swift // Typed header keys (e.g., .contentType) └── HTTPStatusCode.swift // HTTP status code wrapper with handy properties Data models and requests Data models and requests In this section we define: In this section we define: data models coming from the server (User); requests implementing Requestable; and the HTTPMethod type specifying the HTTP method. data models coming from the server (User); requests implementing Requestable; and the HTTPMethod type specifying the HTTP method. Response models (Responses.swift) Response models (Responses.swift) User model (User) struct User: Decodable { let id: Int let name: String let email: String } struct User: Decodable { let id: Int let name: String let email: String } This is a simple user model. It comes from the server and is automatically parsed by JSONDecoder. Example JSON: { "id": 1, "name": "Leanne Graham", "email": "leanne@example.com" } { "id": 1, "name": "Leanne Graham", "email": "leanne@example.com" } Requests (Requests.swift) Requests (Requests.swift) Each struct implements Requestable and describes a specific API request. This makes the code declarative and predictable. Fetching user list struct FetchUserListRequest: Requestable { let path = "users/" } struct FetchUserListRequest: Requestable { let path = "users/" } GET is the default (set in Requestable) path defines where the request goes No parameters or body — just a simple GET without query/body GET is the default (set in Requestable) path defines where the request goes No parameters or body — just a simple GET without query/body The Requestable abstraction The Requestable abstraction So we don’t have to manually write URLRequest, parameters, headers, body — we describe HTTP requests declaratively via Requestable. Requestable: describing the request protocol Requestable { associatedtype Body: Encodable = Never var method: HTTPMethod { get } var path: String { get } var parameters: [URLQueryItem] { get } var headers: [HTTPHeaderKey: String] { get } var body: Body? { get } func fullURL(baseURL: URL) -> URL? { guard let url = URL(string: path, relativeTo: baseURL), var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil } if !parameters.isEmpty { urlComponents.queryItems = parameters } return urlComponents.url } } protocol Requestable { associatedtype Body: Encodable = Never var method: HTTPMethod { get } var path: String { get } var parameters: [URLQueryItem] { get } var headers: [HTTPHeaderKey: String] { get } var body: Body? { get } func fullURL(baseURL: URL) -> URL? { guard let url = URL(string: path, relativeTo: baseURL), var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil } if !parameters.isEmpty { urlComponents.queryItems = parameters } return urlComponents.url } } Each request (e.g., FetchUserListRequest) implements Requestable. The protocol defines everything needed to build an HTTP request: Property Purpose method HTTP method (GET, POST, …) path Final part of URL (e.g., users/1) parameters Query params (?postId=1) headers Headers (Content-Type, Authorization) body Request body (if any) Property Purpose method HTTP method (GET, POST, …) path Final part of URL (e.g., users/1) parameters Query params (?postId=1) headers Headers (Content-Type, Authorization) body Request body (if any) Property Purpose Property Property Purpose Purpose method HTTP method (GET, POST, …) method method HTTP method (GET, POST, …) HTTP method (GET, POST, …) path Final part of URL (e.g., users/1) path path Final part of URL (e.g., users/1) Final part of URL (e.g., users/1) parameters Query params (?postId=1) parameters parameters Query params (?postId=1) Query params (?postId=1) headers Headers (Content-Type, Authorization) headers headers Headers (Content-Type, Authorization) Headers (Content-Type, Authorization) body Request body (if any) body body Request body (if any) Request body (if any) Body: Encodable = Never Body: Encodable = Never This means: If a request has no body (GET) — you don’t need to define it If there is a body (POST, PUT) — you explicitly specify the type (e.g., Post) If a request has no body (GET) — you don’t need to define it If a request has no body (GET) — you don’t need to define it If there is a body (POST, PUT) — you explicitly specify the type (e.g., Post) If there is a body (POST, PUT) — you explicitly specify the type (e.g., Post) Default values Default values extension Requestable { var method: HTTPMethod { .GET } var parameters: [URLQueryItem] { [] } var headers: [HTTPHeaderKey: String] { [:] } var body: Never? { nil } } extension Requestable { var method: HTTPMethod { .GET } var parameters: [URLQueryItem] { [] } var headers: [HTTPHeaderKey: String] { [:] } var body: Never? { nil } } For a simple GET without params/body — you don’t even need to implement these fields. Just provide path: struct FetchUserListRequest: Requestable { let path = "users/" } struct FetchUserListRequest: Requestable { let path = "users/" } fullURL(baseURL:) method fullURL(baseURL:) method func fullURL(baseURL: URL) -> URL? { guard let url = URL(string: path, relativeTo: baseURL), var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil } if !parameters.isEmpty { urlComponents.queryItems = parameters } return urlComponents.url } func fullURL(baseURL: URL) -> URL? { guard let url = URL(string: path, relativeTo: baseURL), var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil } if !parameters.isEmpty { urlComponents.queryItems = parameters } return urlComponents.url } This function builds the final URL: This function builds the final URL: if parameters exist, they are added as query string; path is joined with baseURL; returns the fully ready-to-use link. if parameters exist, they are added as query string; if parameters exist, they are added as query string; path is joined with baseURL; path is joined with baseURL; returns the fully ready-to-use link. returns the fully ready-to-use link. HTTPMethod (HTTPMethod.swift) HTTPMethod (HTTPMethod.swift) enum HTTPMethod: String { case GET case POST case PUT case DELETE } enum HTTPMethod: String { case GET case POST case PUT case DELETE } A simple but necessary enum. Lets you explicitly specify HTTP methods, instead of using strings like "GET" or "POST". This way there are fewer errors, autocomplete works, and it’s centralized for extension (e.g., you can add .PATCH if needed). Type-safe headers — HTTPHeaderKey Type-safe headers — HTTPHeaderKey Working with headers as strings = bugs waiting to happen. Instead we use a dedicated type: struct HTTPHeaderKey: ExpressibleByStringLiteral, Hashable { let rawValue: String init(stringLiteral value: String) { rawValue = value } } struct HTTPHeaderKey: ExpressibleByStringLiteral, Hashable { let rawValue: String init(stringLiteral value: String) { rawValue = value } } And extend it with typed keys: extension HTTPHeaderKey { static let contentType = HTTPHeaderKey("Content-Type") static let accept = HTTPHeaderKey("Accept") static let authorization = HTTPHeaderKey("Authorization") static let userAgent = HTTPHeaderKey("User-Agent") } extension HTTPHeaderKey { static let contentType = HTTPHeaderKey("Content-Type") static let accept = HTTPHeaderKey("Accept") static let authorization = HTTPHeaderKey("Authorization") static let userAgent = HTTPHeaderKey("User-Agent") } Now in requests we write like this: headers = [.contentType: "application/json"] headers = [.contentType: "application/json"] It’s convenient, readable, and the IDE will hint if you forget something. Section recap: Section recap: Requests are fully declarative — each describes what it does, not how it does it. Using Requestable makes code easily extensible and type-safe. Clear separation of Encodable and Decodable models shows who “sends” and who “receives”. HTTPMethod and HTTPHeaderKey eliminate magic strings and simplify maintenance. Requests are fully declarative — each describes what it does, not how it does it. Requests are fully declarative — each describes what it does, not how it does it. Using Requestable makes code easily extensible and type-safe. Using Requestable makes code easily extensible and type-safe. Clear separation of Encodable and Decodable models shows who “sends” and who “receives”. Clear separation of Encodable and Decodable models shows who “sends” and who “receives”. HTTPMethod and HTTPHeaderKey eliminate magic strings and simplify maintenance. HTTPMethod and HTTPHeaderKey eliminate magic strings and simplify maintenance. APIClient and its dataTask APIClient and its dataTask Now that we’ve described what we want to send (Requestable) and what we expect to receive (Resource), it’s time to implement the executor itself that will send the request over the network, get the response, validate it, parse it — and return it back. All of this is handled by APIClient: final class APIClient { private let baseURL: URL private let urlSession = URLSession init( basePath: String = "https://jsonplaceholder.typicode.com/", urlSession: URLSession = .shared ) { self.baseURL = URL(string: basePath)! self.urlSession = urlSession } func dataTask<Response, Request>( with resource: Resource<Response, Request> ) async throws -> Response { let urlRequest = try URLRequest( request: resource.request, baseURL: URL(string: basePath)! ) let (data, response) = try await urlSession.data(for: urlRequest) return try resource.decode(data) } } final class APIClient { private let baseURL: URL private let urlSession = URLSession init( basePath: String = "https://jsonplaceholder.typicode.com/", urlSession: URLSession = .shared ) { self.baseURL = URL(string: basePath)! self.urlSession = urlSession } func dataTask<Response, Request>( with resource: Resource<Response, Request> ) async throws -> Response { let urlRequest = try URLRequest( request: resource.request, baseURL: URL(string: basePath)! ) let (data, response) = try await urlSession.data(for: urlRequest) return try resource.decode(data) } } How the method works Step 1: Building the URLRequest Step 1: Building the URLRequest let urlRequest = try URLRequest( request: resource.request, baseURL: URL(string: basePath)! ) let urlRequest = try URLRequest( request: resource.request, baseURL: URL(string: basePath)! ) Here we use a custom init on URLRequest, which accepts our Requestable and assembles the final request. Step 2: Calling URLSession Step 2: Calling URLSession let (data, response) = try await urlSession.data(for: urlRequest) let (data, response) = try await urlSession.data(for: urlRequest) Asynchronous call. We get data and response. URLRequest extension extension URLRequest { init( request: some Requestable, baseURL: URL ) throws { guard let fullURL = request.fullURL(baseURL: baseURL) else { throw NSError( domain: "com.myapp.Networking", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"] ) } self.init(url: fullURL) self.httpMethod = request.method.rawValue for (key, value) in request.headers { self.addValue(value, forHTTPHeaderField: key.rawValue) } } } extension URLRequest { init( request: some Requestable, baseURL: URL ) throws { guard let fullURL = request.fullURL(baseURL: baseURL) else { throw NSError( domain: "com.myapp.Networking", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"] ) } self.init(url: fullURL) self.httpMethod = request.method.rawValue for (key, value) in request.headers { self.addValue(value, forHTTPHeaderField: key.rawValue) } } } What happens under the hood What happens under the hood Building the URL via request.fullURL(...) guard let fullURL = request.fullURL(baseURL: baseURL) else { throw NSError( domain: "com.myapp.Networking", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"] ) } self.init(url: fullURL) Setting the HTTP method self.httpMethod = request.method.rawValue Adding headers (APIEnvironment + Requestable) for (key, value) in request.headers { self.addValue(value, forHTTPHeaderField: key.rawValue) } Building the URL via request.fullURL(...) guard let fullURL = request.fullURL(baseURL: baseURL) else { throw NSError( domain: "com.myapp.Networking", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"] ) } self.init(url: fullURL) Building the URL via request.fullURL(...) guard let fullURL = request.fullURL(baseURL: baseURL) else { throw NSError( domain: "com.myapp.Networking", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"] ) } self.init(url: fullURL) guard let fullURL = request.fullURL(baseURL: baseURL) else { throw NSError( domain: "com.myapp.Networking", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"] ) } self.init(url: fullURL) Setting the HTTP method self.httpMethod = request.method.rawValue Setting the HTTP method self.httpMethod = request.method.rawValue self.httpMethod = request.method.rawValue Adding headers (APIEnvironment + Requestable) for (key, value) in request.headers { self.addValue(value, forHTTPHeaderField: key.rawValue) } Adding headers (APIEnvironment + Requestable) for (key, value) in request.headers { self.addValue(value, forHTTPHeaderField: key.rawValue) } for (key, value) in request.headers { self.addValue(value, forHTTPHeaderField: key.rawValue) } request.headers — request-specific headers Resource: How to handle the response Resource: How to handle the response We then wrap the request in a Resource, which knows how to decode the response. struct Resource<Response, Request: Requestable> { let request: Request let decode: (Data) throws -> Response } struct Resource<Response, Request: Requestable> { let request: Request let decode: (Data) throws -> Response } Here we have two parameters: Here we have two parameters: Request: our Requestable, describing what and how to request. Response: the type we expect to receive in return. Request: our Requestable, describing what and how to request. Response: the type we expect to receive in return. Decodable response Decodable response extension Resource where Response: Decodable { init(request: Request) { self.init(request: request) { data in return try JSONDecoder.snakeCaseConverting.decode(Response.self, from: data) } } } extension Resource where Response: Decodable { init(request: Request) { self.init(request: request) { data in return try JSONDecoder.snakeCaseConverting.decode(Response.self, from: data) } } } This way we don’t have to manually write decoding — Resource plugs in the logic itself if we expect Decodable. Example: Example: let request = FetchUserListRequest() let resource = Resource<[User],FetchUserListRequest>(request: request) let request = FetchUserListRequest() let resource = Resource<[User],FetchUserListRequest>(request: request) Empty response (Void) Empty response (Void) extension Resource where Response == Void { init(request: Request) { self.init(request: request) { _ in return () } } } extension Resource where Response == Void { init(request: Request) { self.init(request: request) { _ in return () } } } If the server doesn’t return anything in the body (for example, 201 Created, 204 No Content) — just use Resource<Void, ...>, and the decoder does nothing. extension JSONDecoder { static var snakeCaseConverting: JSONDecoder { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder } } extension JSONDecoder { static var snakeCaseConverting: JSONDecoder { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder } } This saves us from writing CodingKeys in models. JSON like: { "user_id": 123, "user_name": "John" } { "user_id": 123, "user_name": "John" } automatically maps to: struct User: Decodable { let userId: Int let userName: String } struct User: Decodable { let userId: Int let userName: String } extension JSONEncoder { static var snakeCaseConverting: JSONEncoder { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase return encoder } } extension JSONEncoder { static var snakeCaseConverting: JSONEncoder { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase return encoder } } Converts camelCase → snake_case Saves us from writing CodingKeys Converts camelCase → snake_case Saves us from writing CodingKeys Section recap Section recap One APIClient handles all types of requests (GET, POST, Void, Decodable, custom decode) We separate request description (Requestable), response handling (Resource), and transport (URLSession). No duplicated logic, everything is declarative. Code is easily extensible — just create a new Requestable implementation. Support for Void, Decodable, custom decoding — already built-in. It’s 10x better than writing URLRequest manually every time. One APIClient handles all types of requests (GET, POST, Void, Decodable, custom decode) One APIClient handles all types of requests (GET, POST, Void, Decodable, custom decode) We separate request description (Requestable), response handling (Resource), and transport (URLSession). We separate request description (Requestable), response handling (Resource), and transport (URLSession). No duplicated logic, everything is declarative. No duplicated logic, everything is declarative. Code is easily extensible — just create a new Requestable implementation. Code is easily extensible — just create a new Requestable implementation. Support for Void, Decodable, custom decoding — already built-in. Support for Void, Decodable, custom decoding — already built-in. It’s 10x better than writing URLRequest manually every time. It’s 10x better than writing URLRequest manually every time. ViewModel: linking API with UI ViewModel: linking API with UI @Observable final class ViewModel { private(set) var userList: [User] = [] private let apiClient: APIClient init(apiClient: APIClient = .init()) { self.apiClient = apiClient } } @Observable final class ViewModel { private(set) var userList: [User] = [] private let apiClient: APIClient init(apiClient: APIClient = .init()) { self.apiClient = apiClient } } ViewModel methods ViewModel methods Fetching user list func fetchUserList() async { do { let request = FetchUserListRequest() let resource = Resource<[User], FetchUserListRequest>(request: request) let response = try await apiClient.dataTask(with: resource) userList = response } catch { dump(error) } } func fetchUserList() async { do { let request = FetchUserListRequest() let resource = Resource<[User], FetchUserListRequest>(request: request) let response = try await apiClient.dataTask(with: resource) userList = response } catch { dump(error) } } What happens: What happens: Create the request — FetchUserListRequest(), which implements Requestable. Wrap it in Resource<[User]>, where [User] is the expected response type. Call apiClient.dataTask — it handles everything: builds URLRequest, sends it, parses it. Assign the result to userList — the UI updates automatically. Create the request — FetchUserListRequest(), which implements Requestable. Wrap it in Resource<[User]>, where [User] is the expected response type. Call apiClient.dataTask — it handles everything: builds URLRequest, sends it, parses it. Assign the result to userList — the UI updates automatically. Conclusion Conclusion And that’s about it :) So what do we have at this point: So what do we have at this point: Declarative requests via Requestable, without manually constructing URLs and headers; Flexible APIClient that handles all requests; Type-safe models, without Any, Dictionary, or other mess; Declarative requests via Requestable, without manually constructing URLs and headers; Declarative requests via Requestable, without manually constructing URLs and headers; Flexible APIClient that handles all requests; Flexible APIClient that handles all requests; Type-safe models, without Any, Dictionary, or other mess; Type-safe models, without Any, Dictionary, or other mess; In the next part, we’ll extend the networking layer into a full-fledged system and cover everything needed for a proper networking setup. That’s all, for now. See you next time! Useful links: Useful links: Project repo: https://github.com/slip-def/SwiftyNetworking Video reference: https://www.youtube.com/watch?v=xV2DVQ8sw7o Project repo: https://github.com/slip-def/SwiftyNetworking https://github.com/slip-def/SwiftyNetworking Video reference: https://www.youtube.com/watch?v=xV2DVQ8sw7o https://www.youtube.com/watch?v=xV2DVQ8sw7o