Introduction

Almost all the mobile apps needs to access the internet. In Swift, you can use the URLSession API for network related tasks, such as consuming web services or downloading files (as images to be displayed in the UI).
It's very easy to perform a simple network call, but you likely won't be limited to those. In a real app, it's needed to create robust solutions to complex network logic: modularity, thread-management, error handling, cancellation and more. Creating a network layer then could be the best choice in this case. Let's see how to do without any third-part library.


What is URLSession

URLSession is a class, part of the URL Loading System, a collection of APIs present in Swift Foundation to interact with web servers through URLs. There are other classes in this collection, as URLSessionTask and URLSessionConfiguration that are also important when working with networking, but URLSession objects are the most fundamental structure to handle this kind of tasks.

Creating tasks from URL sessions

Creating tasks from URL sessions - Apple Developer - URL Loading System

The URLSession has a Singleton instance with a basic configuration, that can be used for simple calls, let's see an example consuming the Cat Fact API

let url = URL(string: "https://catfact.ninja/fact")

let task = URLSession.shared.dataTask(with: url!) { (data, _, _) in
    guard let data, 
          let stringResponse = String(data: data, encoding: .utf8) else {
        print("Invalid response")

        return
    }

    print("The task finished with response: \(stringResponse)")
}

task.resume()

print("The task started to being executed")

Analyzing the code above:

  1. We create an URL object using the initializer from a String. This initializer creates an Optional value;
  2. We call the dataTask(with:) function of the URLSession singleton object. This method receives an URL object and a completion handler as arguments. The later will be executed when the request is finished. The function returns an object of type URLSessionDataTask;
  3. With task.resume() we are manually starting the task execution, that will be performed asynchronously, in a background thread. We can also cancel the network request at any time before its completion, by calling task.cancel(). We're seeing more of that later in this article.

Thread management

As mentioned in the previous section at item 3, the dataTask(with) function automatically executes the network call on a background thread:

URLSession.shared.dataTask(with: url!) { (data, _, _) in
    print(Thread.isMainThread ? "Is Main Thread" : "Not Main Thread")
}.resume()

Console Output:

Not Main Thread

That's because requests are heavy tasks, and not executing them in a background thread can lead to a bad UX, since the UI is handled in the main thread by default. Executing heavy tasks in the main thread makes the UI freeze until the task is completed. However, if you need to display a network response in the view, you'll need to manually perform that task of updating the UI in the Main Thread, otherwise you can face issues:

import UIKit

lazy var label: UILabel = {
    // ...
}()

URLSession.shared.dataTask(with: url!) { [label] (data, _, _) in
    guard let data,
          let stringResponse = String(data: data, encoding: .utf8) else {
        print("Invalid response")

        return
    }

    label.text = stringResponse // Runtime warning: ⚠️ Unable to encode UIView on a non-main thread
}.resume()

In order to make that work, you'll need some strategy to guarantee that label.text = stringResponse is executed on the Main Thread, for example:

DispatchQueue.main.async {
    label.text = stringResponse
}

Why Creating a Separate Layer

We could observe that thread management is an important subject to take care when dealing with network, that can become complex. Also, authentication, error handling and resources management are so important. Having a separate layer only to deal with networking logic is a good way to have more isolated and agnostic business logic.
Imagine a scenario where you need to store some data in your app. Initially, you decide to store it locally, but in a future version this data will be sent to a backend service. Using abstraction helps this future refactor:

let userService: UserService = LocalStorageUserService()

func save(user: User) async {
    await userService.save(user)
}

The piece of code above is about business logic. In future, if you want to create an APIUserService, you can isolate everything related to network inside the network layer, and the usage of it in your business code will be the same.

Another reason for creating a network layer is sharing logic between apps. You may have multiple apps that consumes a same service, then you can create a package with all the requirements for that specific service, and share across all apps.


Creating the Network Layer

We'll now start creating our Network Layer. This implementation above is just a suggestion, that should be adapted to the reality of your project. There's no "right way" to do it, but it's a good starting point. We are going to split our Network Layer in some steps: API Client, Authentication, Endpoint Model, Service Abstraction, Concrete Service, Error Handling and Cancellation.

We are using DummyJSON for that, that is a public API with several different endpoints, ideal for studying how to create API clients (such as mobile apps, websites and more).

API Client

The first structure we are creating is the API Client. Here, we can consider that a single app can have multiple API integrations, so we’re making it as generic as possible. The DummyJSON API is a REST service that uses JWT as authentication method, so we can start with an abstraction like that:

import Foundation

protocol JWTAPIClient {
    var baseURL: URL { get }
    var jwt: String? { get }

    func auth(with credentials: Credentials, completion: @escaping (Result<Void, Error>) -> Void) -> URLSessionTask
}

struct Credentials: Codable {
    var username: String
    var password: String
}

Here we have a protocol that holds the value of the API URL, such as the current JWT token. It also has a signature for a function that performs the API authentication.
The auth function receives as first argument the credentials object, and a completion handler as second argument. The later is an escaping closure, with a Void return type that receives a Result of Void and Error. Since the execution of the network call is an asynchronous task, that has an uncertain result (it can succeed or fail), it's important to create this completion handler with this parameter.

That's a definition for an API Client that uses JWT authentication, it can be used for different API integrations, but now we are creating the one for consuming the DummyJSON API:

import Foundation

final class DummyJSONAPIClient: JWTAPIClient {
    var baseURL: URL {
        URL(string: "https://dummyjson.com/")!
    }
    var jwt: String?

    func auth(with credentials: Credentials, completion: @escaping (Result<Void, any Error>) -> Void) -> URLSessionTask {
        // authentication logic here
    }
}

We just created a DummyJSONAPIClient class that conforms to JWTAPIClient. The idea of the auth function is to receive credentials, authenticate and store the received JWT to be used in restricted calls. We'll implement all that logic in a bit.

Authentication

Now, we're going to implement the authentication logic. Reading the DummyJSON documentation, we can notice that the auth/logic endpoint can be used to authenticate, and the auth/me endpoint is an example of a restricted service, that needs to receive a valid JWT token to work. Let's focus in the first endpoint, adding implementation to the DummyJSONAPIClient.auth function. We'll create an URLRequest object to perform this call:

func auth(with credentials: Credentials, completion: @escaping (Result<Void, any Error>) -> Void) -> URLSessionTask {
    var url = baseURL
    url.appendPathComponent("auth/login")

    var request = URLRequest(url: url)
    request.httpMethod = HTTPMethod.POST.rawValue
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    request.httpBody = try? JSONEncoder().encode(credentials)
}

enum HTTPMethod: String {
    case GET
    case POST
    case DELETE
    case PUT
    case PATCH
}

In the piece of code above, we did the following:

  1. Created a copy of baseURL and added the authentication endpoint path to it
  2. Created an URLRequest object with the auth endpoint URL
  3. Set the HTTP method to POST, following the documentation
  4. Set the Content-Type header to application/json
  5. Set the request body with the credentials value. Notice that it is being encoded in JSON

Creating the response model

Another thing that we can verify on DummyJSON documentation is the expected response for the authentication service.

{
  "id": 1,
  "username": "emilys",
  "email": "[email protected]",
  "firstName": "Emily",
  "lastName": "Johnson",
  "gender": "female",
  "image": "https://dummyjson.com/icon/emilys/128",
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

We can now create a struct to decode the response data:

struct AuthResponse: Codable {
    let id: Int
    let username: String
    let email: String
    let firstName: String
    let lastName: String
    let gender: String
    let image: String
    let accessToken: String
    let refreshToken: String
}

Notice that conforming to the Codable protocol is important to use the decoders in future.
Now that we have a model to decode our response, we can add the code to perform the network call. We should add this piece of code just below our URLRequest creation, inside the auth function:

let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
    if let error {
        completion(.failure(error))

        return
    }

    guard let data else {
        // TODO: Handle this scenario in future
        print("Nil data")

        return
    }

    guard let authResponse = try? JSONDecoder().decode(AuthResponse.self, from: data) else {
        // TODO: Handle this scenario in future
        print("Failed to decode JSON")

        return
    }

    self?.jwt = authResponse.accessToken

    completion(.success(()))
}

task.resume()

return task

The dataTask(with:) function performs the network call asynchronously, with the giving URLRequest object, and return a URLSessionTask object. Inside the completion handler that is being passed, it's being executed the following actions:

  • Checking if the error object is not nil, and if it isn't, failing the completion result
  • Unwrapping the data variable (the error handling for when it's nil is not implemented yet)
  • Trying to decode the JSON response to the model we just created
  • Storing the received token in the client instance to be used in future

Endpoint Model

Now that we have an working authentication method, we can start creating our Endpoints. First, we'll create a generic representation of any endpoint, then we'll create one different object for each endpoint that our app will consume. Let's see the generic one first:

protocol Endpoint {
    // The path for the endpoint. This is the part of the URL after the host of the API
    var path: String { get }
    // Headers to be sent in the request
    var headers: [String: String]? { get }
    // The HTTP method for this endpoint
    var method: HTTPMethod { get }
    // Parameters sent in the request body. It can't be used in `GET` requests
    var bodyParameters: [String: Any?]? { get }
    // Parameters sent in the URL.
    var queryParameters: [String: String]? { get }
}

This protocol defines all properties an endpoint might need. So now, we can create our concrete representation of an endpoint. Let's start with the GET auth/me one:

struct GetCurrentUserEndpoint { }

extension GetCurrentUserEndpoint: Endpoint {
    var path: String { "/auth/me" }
    var method: HTTPMethod { .GET }

    var headers: [String : String]? {
        nil
    }

    var bodyParameters: [String : Any?]? {
        nil
    }

    var queryParameters: [String : String]? {
        nil
    }
}

Observe that this endpoint doesn't require any specific value in the headers, body, neither query, so we need to implement the protocol properties returning nil. It's very common that we have several endpoints that doesn't need these properties. But the path and method are always mandatory. So we can apply an improvement to that architecture, adding default values to the protocol:

extension Endpoint {
    var headers: [String: String]? {
        nil
    }

    var bodyParameters: [String: Any?]? {
        nil
    }

    var queryParameters: [String: String]? {
        nil
    }
}

Then our specific endpoint now can be as simple as that:

struct GetCurrentUserEndpoint { }

extension GetCurrentUserEndpoint: Endpoint {
    var path: String { "/auth/me" }
    var method: HTTPMethod { .GET }
}

Service Abstraction

We are now creating a simple abstraction to services in general. It will be just a protocol that requires the service to receive an API client, we're calling it NetworkService:

protocol NetworkService {
    var apiClient: JWTAPIClient { get }
}

The next step will be adding a new function to our generic JWTAPIClient. Just below the auth(with:credentials) function, we'll add a function to perform calls to endpoints. This is the signature in the protocol:

protocol JWTAPIClient {
    // ...

    func request(from endpoint: Endpoint, completion: @escaping (Result<Data?, Error>) -> Void) -> URLSessionTask
}

Then we can adapt our concrete DummyJSONAPIClient class to implement the new required method:

final class DummyJSONAPIClient: JWTAPIClient {
    // ...
    func request(from endpoint: any Endpoint, completion: @escaping (Result<Data?, any Error>) -> Void) -> URLSessionTask {
        var url = baseURL
        url.appendPathComponent(endpoint.path)

        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        if let jwt = jwt {
            request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
        }

        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            // Completion implementation here
        }

        task.resume()

        return task
    }
}

Look how similar to the auth(with:credentials) function it is, but with some more generic implementation:

  • A copy of baseURL is made
  • A path component is added to it, but now the endpoint.path instead of a hard-coded "auth/login" string
  • The HTTP method is also set from what is received from the endpoint object, instead of a hard-coded value

Everything else is pretty the same, except by the Authorization header, that we are sending with the stored value from the authentication. That's what will make our restricted calls work.

Now, we are going to add the implementation to the completion handler. We should replace the // Completion implementation here with:

if let error {
    completion(.failure(error))

    return
}

completion(.success(data))

Now we have our base request function, and we are able to create the specific one.

Concrete Service

We are almost done with the creation of the logic to fetch the Get current auth user endpoint. Similar to what we did for the authentication method, we need to create a model to decode the endpoint response. We are creating this one, that doesn't support all the attributes in the response, but it is enough for our purpose today:

struct UserResponse: Codable {
    let id: Int
    let firstName: String
    let lastName: String
    let maidenName: String
    let age: Int
    let gender: String
    let email: String
    let phone: String
    let username: String
    let password: String
    let birthDate: String
    let image: String
    let bloodGroup: String
    let height: Double
    let weight: Double
    let eyeColor: String
}

Now it's time to create our concrete implementation of our service, that we're calling UserService. It should conform with our NetworkService protocol:

final class UserService: NetworkService {
    var apiClient: JWTAPIClient

    init(apiClient: JWTAPIClient = DummyJSONAPIClient()) {
        self.apiClient = apiClient
    }
}

We can create functions for each user-related endpoint we want to fetch in our API inside this class. Let's create one in an extension, for code organization purposes:

// MARK: Endpoints
extension UserService {
    func currentUser(completion: @escaping (Result<UserResponse, Error>) -> Void) -> URLSessionTask{
        let endpoint = GetCurrentUserEndpoint()

        let task = apiClient.request(from: endpoint) { [weak self] result in
            // Completion implementation here
        }

        return task
    }
}

I'll break up the code above now:

  • A GetCurrentUserEndpoint object is created
  • The API client request(from:) function is called passing the desired endpoint, storing locally the URLSessionTask that it creates
  • The task is returned to be handled for who is calling it

We can create other functions inside services very similar to this one, just changing the endpoint object we are passing to the request(from:) function, to make requests to any endpoint in our API.

The request(from:) function calls the completion handler with some Data received from the web service, and we need to decode it to our UserResponse. Let's create a function to it:

// MARK: Decoding
extension UserService {
    private func decodeUser(from data: Data) -> UserResponse? {
        do {
            let decoder = JSONDecoder()

            /* 
            You can add here specific rules to your decoder if needed, 
            such as date/time format
            */

            return try decoder.decode(UserResponse.self, from: data)
        } catch {
            print("Failed to decode user: \(error)")
            return nil
        }
    }
}

Let's now implement the response handling of our call, just replacing // Completion implementation here in the currentUser() function with:

switch result {

case .success(let data):
    // TODO: Handle error
    guard let self else { return }

    // TODO: Handle error
    guard let data else { return }

    // TODO: Handle error
    guard let userResponse = self.decodeUser(from: data) else { return }

    completion(.success(userResponse))

case .failure(let error):
    completion(.failure(error))
}

We have now all the needed code to retrieve the data of the current authenticated user.

Error Handling

It's very important to create strategies for when requests fail. Especially in mobile apps, where connectivity can be more unstable.
Our first step will be creating a custom enum for possible errors that we may face. Let's use this one for now:

enum NetworkError: Error {
    case dataTaskError(Error)
    case systemError(String)
    case emptyData
    case decodingError(String)
    case httpError(Int)
}

Just scanning our current project and enumerating the TODOs it was possible to identify these failing situations. Note that some cases receives an argument, with details about the issue.

Let's refactor our current code to make our Network Layer throw the desired errors. First, we'll update the JWTAPIClient, changing the Result types:

protocol JWTAPIClient {
    var baseURL: URL { get }
    var jwt: String? { get }

    // Replaced here "Error" with "NetworkError"
    func auth(with credentials: Credentials, completion: @escaping (Result<Void, NetworkError>) -> Void) -> URLSessionTask

    // Replaced here "Error" with "NetworkError"
    func request(from endpoint: Endpoint, completion: @escaping (Result<Data?, NetworkError>) -> Void) -> URLSessionTask
}

Then, we'll do the same to the DummyJSONAPIClient class:

final class DummyJSONAPIClient: JWTAPIClient {
    // ...
    func auth(with credentials: Credentials, completion: @escaping (Result<Void, NetworkError>) -> Void) -> URLSessionTask {
        // ...
    }

    func request(from endpoint: any Endpoint, completion: @escaping (Result<Data?, NetworkError>) -> Void) -> URLSessionTask {
        // ...
    }
}

You should see some build errors now in the DummyJSONAPIClient class, since we are passing generic Error objects to the result completion that expects a concrete NetworkError. Let's fix that by passing a .dataTaskError in those places:

// Replace
completion(.failure(error))
// With
completion(.failure(.dataTaskError(error)))

We can now start replacing our TODOs with the properly error handling. Let's start in the DummyJSONAPIClient.auth function:

guard let data else {
    completion(.failure(.emptyData))

    return
}

guard let authResponse = try? JSONDecoder().decode(AuthResponse.self, from: data) else {
    completion(.failure(.decodingError("Failed to decode AuthResponse")))

    return
}

In the URLSession.shared.dataTask(with:) completion handler, we receive three parameters, data, response and error, but so far we used only the first and the last one. The response parameter is important to receive metadata about the network call, such as errors on server-side. If an error occur inside the API, the error attribute returns nil, so we need to check for this kind of issues in the response object. A very simple way to check it is validating the HTTP Response Code. The HTTP protocol defines that any code between 200 and 299 is considered success, so we can have a very basic error handling strategy by doing this:

guard let httpResponse = response as? HTTPURLResponse else {
    completion(.failure(.systemError("Response is not a HTTPURLResponse")))
    return
}

guard 200..<299 ~= httpResponse.statusCode else {
    completion(.failure(.httpError(httpResponse.statusCode)))
    return
}

Adding this piece of code above in both auth and request functions of the DummyJSONAPIClient class is enough for a simple validation.

We also have other 3 TODOs in the UserService.currentUser. Let's address them:

// Replaced here `Error` with `NetworkError`
func currentUser(completion: @escaping (Result<UserResponse, NetworkError>) -> Void) -> URLSessionTask{

    // ...

    guard let self else {
        completion(.failure(.systemError("Nil self in UserService.currentUser")))

        return
    }

    guard let data else {
        completion(.failure(.emptyData))

        return
    }

    guard let userResponse = self.decodeUser(from: data) else {
        completion(.failure(.decodingError("Failed to decode UserResponse")))

        return
    }

    // ...

}

Now our Network Layer responds with specific errors when it fails, allowing the client code to behave properly (display a friendly message to the user, and also log the reason of the failing, helping with debugging, for example).

Cancellation

Our Network Layer now allows us to cancel pending requests in order to save resources consumption. Imagine for example that a request starts in the Home screen, but before it finishes the user taps in a button that redirects him to the Settings screen. That network call is not needed anymore, so we can cancel it, since we receive the URLSessionTask object. This is an hypothetical piece of code doing it:

class HomeViewController: UIViewController {

    var userRequestTask: URLSessionTask? = nil

    func getUser() {
        let apiClient = DummyJSONAPIClient()
        let userService = UserService(apiClient: apiClient)

        userRequestTask = userService.currentUser { [weak self] result in
            // ...
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        // Cancelling a possible request since the user is leaving the page
        userRequestTask?.cancel()
    }
}

Testing

We can write some Unit Tests to verify that our Network Layer is properly working. Let's see an example with Swift Testing that tests our authentication logic:

import Foundation
import Testing

@testable import URLSessionNetworkLayer

@Test("Test that calling the auth endpoint stores the JWT") func testAuth() async throws {
    let apiClient = DummyJSONAPIClient()
    let credentials = Credentials(username: "emilys", password: "emilyspass")

    await withCheckedContinuation { continuation in
        let _ = apiClient.auth(with: credentials) { result in
            if case .success = result {
                #expect(apiClient.jwt != nil)
            } else {
                Issue.record("Authentication Failed")
            }

            continuation.resume()
        }
    }
}

If you're not familiar with Swift Testing, I wrote a post detailing it here: Introduction to Swift Testing: Apple's New Testing Framework

Here we create a DummyJSONAPIClient object, a Credentials object with valid values, and then we call the auth function, checking if the jwt token is stored correctly.

🚨 Note: At the moment that I write this post, there's an open issue in Swift Testing about recording test errors in detached tasks, as we're doing. There's nothing wrong with the piece of code above, but you can face crashes in CI/CD environment that runs tests if a test fail.

It's also possible test if the request that requires authentication is working as expected:

@Test("Test that fetching current user before authentication returns error") func testCurrentUserBeforeAuth() async throws {
    let apiClient = DummyJSONAPIClient()

    let userService = UserService(apiClient: apiClient)
    await withCheckedContinuation { continuation in
        // Fetching the current user before authenticating
        let _ = userService.currentUser { result in
            guard case let .failure(error) = result else {
                Issue.record("Network call unexpectedly succeeded")
                continuation.resume()
                return
            }

            guard case let .httpError(statusCode) = error else {
                Issue.record("Network call failed with an unexpected error: \(error)")
                continuation.resume()
                return
            }

            // Checking if the response is "Unauthorized"
            #expect(statusCode == 401)

            continuation.resume()
        }
    }

}

@Test("Test that fetching current user after authentication returns the user")
func testCurrentUserAfterAuth() async throws {
    let apiClient = DummyJSONAPIClient()
    let credentials = Credentials(username: "emilys", password: "emilyspass")

    // Authenticating first
    await withCheckedContinuation { continuation in
        let _ = apiClient.auth(with: credentials) { result in
            guard case .success = result else {
                Issue.record("Authentication Failed")
                continuation.resume()
                return
            }

            continuation.resume()
        }
    }

    let userService = UserService(apiClient: apiClient)
    await withCheckedContinuation { continuation in
        // Fetching the current user after being authenticated
        let _ = userService.currentUser { result in
            guard case .success(let user) = result else {
                Issue.record("Network call failed")
                continuation.resume()
                return
            }

            #expect(user.id == 1)
            #expect(user.firstName == "Emily")
            continuation.resume()
        }
    }
}

Those tests above will guarantee that our Network Layer is still working after changes that we implement, and also are a good tool to test our implementation without needing to launching the app itself.


Additional Resources

You can find the complete project we just created in this repo: url-session-network-layer. Feel free to suggest changes or contribute!


Conclusion

Building a robust network layer with URLSession in Swift is a great way to handle complex networking requirements while keeping your code modular, maintainable, and testable. By abstracting network logic into dedicated components—such as an API clientendpoint models, and service layers—we ensure that our business logic remains clean and decoupled from networking concerns.

Key takeaways from this implementation:

  • Thread management – Ensuring UI updates happen on the main thread while network calls run in the background.
  • Authentication handling – Storing and reusing tokens for secure API calls.
  • Error handling – Providing meaningful feedback when requests fail.
  • Cancellation support – Allowing pending requests to be canceled to save resources.
  • Testability – Writing unit tests to verify networking logic works as expected.

While third-party libraries like Alamofire or Moya can simplify networking, understanding how to build a custom solution with URLSession gives you full control and avoids external dependencies. This approach is especially useful in performance-critical apps or when working in environments where third-party code is restricted.

You can find the complete project on GitHub: url-session-network-layer. Feel free to explore, fork, or contribute!

Have you built a custom network layer before? What challenges did you face? Let’s discuss in the comments!