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:
-
type-safe;
-
scalable without pain;
-
testable;
-
not turning into garbage.
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.
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!
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
Data models and requests
In this section we define:
- data models coming from the server (User);
- requests implementing Requestable;
- and the HTTPMethod type specifying the HTTP method.
Response models (Responses.swift)
User model (User)
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": "[email protected]"
}
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/"
}
- 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
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
}
}
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) |
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)
Default values
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/"
}
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
}
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.
HTTPMethod (HTTPMethod.swift)
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
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 }
}
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")
}
Now in requests we write like this:
headers = [.contentType: "application/json"]
It’s convenient, readable, and the IDE will hint if you forget something.
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.
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)
}
}
How the method works
Step 1: Building the URLRequest
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
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)
}
}
}
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) }
request.headers — request-specific headers
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
}
Here we have two parameters:
- Request: our Requestable, describing what and how to request.
- Response: the type we expect to receive in return.
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)
}
}
}
This way we don’t have to manually write decoding — Resource plugs in the logic itself if we expect Decodable.
Example:
let request = FetchUserListRequest()
let resource = Resource<[User],FetchUserListRequest>(request: request)
Empty response (Void)
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
}
}
This saves us from writing CodingKeys in models. JSON like:
{
"user_id": 123,
"user_name": "John"
}
automatically maps to:
struct User: Decodable {
let userId: Int
let userName: String
}
extension JSONEncoder {
static var snakeCaseConverting: JSONEncoder {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}
}
- Converts camelCase → snake_case
- Saves us from writing CodingKeys
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.
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
}
}
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)
}
}
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.
Conclusion
And that’s about it :)
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;
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:
- Project repo: https://github.com/slip-def/SwiftyNetworking
- Video reference: https://www.youtube.com/watch?v=xV2DVQ8sw7o