All articles
iOS14 min read

URLSession in Swift: Build a Clean and Testable Networking Layer

Tired of tangled networking code in your iOS apps? Learn how to build a clean, testable, and scalable networking layer using URLSession —…

K
Karan Pal
Author
Image generated by AI
Image generated by AI

🧠 Introduction: Why Networking Code Gets Messy (and How We’re About to Fix It)

Let’s be honest — networking code is where many Swift projects quietly descend into chaos. 😅 One moment, you’re writing a simple URLSession call. The next? You’re neck-deep in duplicated code, hard-coded URLs, untestable closures, and error handling that looks more like error hiding.

But it doesn’t have to be this way.

In this article, we’re going to build something better:

A clean, testable, and reusable networking layer using URLSession. No magic libraries. No overengineering. Just solid, modern Swift.

Whether you’re:

— you’re in the right place.

We’ll go step-by-step, layering in best practices, dependency injection (don’t worry, it’s painless), testability, and real-world examples — so by the end, you’ll walk away with networking code you won’t be embarrassed to commit 😎

Ready to build like a pro? Let’s quickly revisit the foundation: what exactly is going on under the hood of URLSession and why it’s still the networking MVP in Swift.

⚙️ The Building Blocks of URLSession (For the Uninitiated)

Before we build a shiny new networking layer, let’s zoom out and look at what powers most of the API calls in Swift apps: URLSession. It’s like the unsung hero of iOS networking — always there, quietly getting the job done, but often misunderstood or misused.

Here’s a quick tour of the key players:

🧱 1. URLSession

The main engine. It handles all the heavy lifting of sending and receiving data from the network.

Think of it as the Uber driver. You tell it where to go (URLRequest), it gets the job done, and sometimes it drops you off in a dark alley if you don’t configure it right 😅

✉️ 2. URLRequest

This is your API call blueprint. You can set:

🚗 3. URLSessionDataTask

This is the actual ride your request takes. You create it with a request and a completion handler, and then you must call .resume() or the car just sits there in the garage 🚙 (Yes, forgetting .resume() is a rite of passage.)

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

🔧 4. URLSessionConfiguration

Use this when you want a bit more control — think timeout settings, caching policy, or custom headers applied to all requests.

🧼 But Here’s the Problem…

While URLSession is great, raw usage doesn’t scale well. Before you know it, every screen is making its own requests, you’ve got 10 different error-handling strategies, and mocking URLSession.shared in tests becomes a nightmare.

That’s where a clean, protocol-driven networking layer comes in. It gives you:

Next up, let’s start architecting this clean networking layer from scratch — without overengineering it into a NASA launch system.

🧱 Designing the Networking Layer Architecture (Clean, Protocol-First & Async/Await)

Time to roll up our sleeves 👨‍💻 and build a solid foundation — not a pile of network spaghetti that breaks the moment you breathe on it.

The goal here:

🔌 Step 1: Define a Networking Protocol

Let’s start with what we want any networking client to do — send a request and give us a decoded response.

protocol NetworkClient {
    func send<T: Decodable>(_ request: URLRequest) async throws -> T
}

💡 Why a protocol?

Because protocols = testability. You can inject a mock client in your unit tests and sleep peacefully at night 🛏️

🏗️ Step 2: Build the Concrete Implementation Using URLSession

Now let’s create a clean, async/await-powered implementation.

final class URLSessionNetworkClient: NetworkClient {
    private let session: URLSession

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

    func send<T: Decodable>(_ request: URLRequest) async throws -> T {
        let (data, response) = try await session.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse,
              200..<300 ~= httpResponse.statusCode else {
            throw NetworkError.invalidResponse
        }

        do {
            return try JSONDecoder().decode(T.self, from: data)
        } catch {
            throw NetworkError.decodingError(error)
        }
    }
}

🎯 Benefits:

📥 Step 3: Injecting It (a.k.a. Dependency Injection Time)

When using this in a service:

class UserService {
    private let networkClient: NetworkClient

    init(networkClient: NetworkClient) {
        self.networkClient = networkClient
    }

    func fetchUsers() async throws -> [User] {
        let url = URL(string: "https://api.example.com/users")!
        let request = URLRequest(url: url)
        return try await networkClient.send(request)
    }
}

You can even swap the real client for a mock one in tests. 🧪

Want to go deeper into DI? You’ll love this: _Dependency Injection in Swift: A Beginner to Advanced Guide_

**Dependency Injection in Swift: A Beginner-to-Advanced Guide** _🧪 Feeling tangled in singletons and tightly-coupled code? Learn how Dependency Injection in Swift can help you write…_medium.comhttps://medium.com/swift-pal/dependency-injection-in-swift-a-beginner-to-advanced-guide-b85378c6f8d2

🧼 Clean Architecture Tip:

Keeping URLSession wrapped behind a protocol also fits nicely into Clean Architecture boundaries. You could place the NetworkClient interface in your domain or core layer and the concrete URLSession implementation in a data/service layer.

If you’re building modular or clean codebases, check out:

👉 Understanding Clean Architecture in iOS

**Understanding Clean Architecture in iOS: A Beginner’s Guide** _Tired of Spaghetti Code? 🍝 Let’s Clean Things Up in iOS!_medium.comhttps://medium.com/swift-pal/understanding-clean-architecture-in-ios-a-beginners-guide-69d09b4883c4

Next stop: 🧪 Testing your networking code like a boss — no more “but it works on my iPhone” excuses.

🧪 Making It Testable (Like, Actually Testable)

Sure, throwing a fake Result.success(data) into your networking layer might technically be “testing,” but let’s be honest — that’s more of a vibes-based unit test 😅

If we really care about testability, we need something that:

Enter: URLProtocolStub, the unsung hero of Swift network testing.

🧰 Step 1: Stubbing URLSession with URLProtocol

By subclassing URLProtocol, we can intercept all network requests sent through a URLSession and respond however we like — no actual API required 🚫🌐

final class URLProtocolStub: URLProtocol {
    static var stub: (data: Data?, response: URLResponse?, error: Error?)?

    override class func canInit(with request: URLRequest) -> Bool {
        true // Intercept all requests
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }

    override func startLoading() {
        if let data = URLProtocolStub.stub?.data {
            client?.urlProtocol(self, didLoad: data)
        }

        if let response = URLProtocolStub.stub?.response {
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        }

        if let error = URLProtocolStub.stub?.error {
            client?.urlProtocol(self, didFailWithError: error)
        }

        client?.urlProtocolDidFinishLoading(self)
    }

    override func stopLoading() {}
}

⚙️ Step 2: Inject a Stubbed Session into Your NetworkClient

let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [URLProtocolStub.self]

let stubbedSession = URLSession(configuration: config)
let client = URLSessionNetworkClient(session: stubbedSession)
🔄 This replaces .shared with a custom session that uses our stub — giving us full control over responses.

🧪 Step 3: Writing Realistic Tests

Let’s say you’re testing UserService.fetchUsers() and want to simulate a 200 response with mocked JSON:

func testFetchUsers_ReturnsDecodedUsers() async throws {
    let mockUsers = [User(id: 1, name: "Karan")]
    let data = try JSONEncoder().encode(mockUsers)

    URLProtocolStub.stub = (
        data: data,
        response: HTTPURLResponse(
            url: URL(string: "https://api.example.com/users")!,
            statusCode: 200,
            httpVersion: nil,
            headerFields: nil
        ),
        error: nil
    )

    let config = URLSessionConfiguration.ephemeral
    config.protocolClasses = [URLProtocolStub.self]
    let client = URLSessionNetworkClient(session: URLSession(configuration: config))
    let service = UserService(networkClient: client)

    let users = try await service.fetchUsers()
    XCTAssertEqual(users.first?.name, "Karan")
}

❌ Simulating a 500 Error

func testFetchUsers_ServerError() async {
    URLProtocolStub.stub = (
        data: nil,
        response: HTTPURLResponse(
            url: URL(string: "https://api.example.com/users")!,
            statusCode: 500,
            httpVersion: nil,
            headerFields: nil
        ),
        error: nil
    )

    let config = URLSessionConfiguration.ephemeral
    config.protocolClasses = [URLProtocolStub.self]
    let client = URLSessionNetworkClient(session: URLSession(configuration: config))
    let service = UserService(networkClient: client)

    do {
        _ = try await service.fetchUsers()
        XCTFail("Expected an error but got success")
    } catch {
        XCTAssertEqual(error as? NetworkError, .invalidResponse)
    }
}

🔐 Bonus: Test-Decodable Failures Too

Want to simulate a decoding failure? Just feed invalid JSON.

URLProtocolStub.stub = (
    data: "Not JSON".data(using: .utf8),
    response: valid200Response,
    error: nil
)

🧠 The Bigger Picture

This testing style:

That’s real testability. The kind you can take home to CI 🫶

🔗 _Want to dive deeper into mocking URLSession using URLProtocol?_
I wrote a 3-minute guide that shows exactly how to intercept requests and return fake responses — perfect for unit testing with async/await:
👉 _Swift URLProtocol Explained in 3 Minutes_

**Swift URLProtocol Explained in 3 Minutes (With Real Example for Testing)** _Want to mock network calls in Swift without setting up a mock server or third-party tool?_medium.comhttps://medium.com/swift-pal/swift-urlprotocol-explained-in-3-minutes-with-real-example-for-testing-ed71afe986d6

🚨 Section 5: Handling Errors Gracefully (Not Just .localizedDescription)

Let’s face it: Swift’s default error reporting is kind of like your mom saying “we’ll see” — vague, unhelpful, and usually means something’s broken.

So let’s fix that.

A robust networking layer should:

All without dumping the raw NSError from the abyss of Foundation.

🧱 Step 1: Define a Proper NetworkError Enum

Instead of blindly passing errors around, we define our own error type.

enum NetworkError: Error, Equatable {
    case invalidURL
    case invalidResponse
    case decodingError(Error)
    case serverError(statusCode: Int)
    case noData
    case underlying(Error)
}

This lets us describe why something failed — not just that it failed.

🔌 Step 2: Update NetworkClient to Throw These Errors

Refactor the send method to decode errors smartly:

func send<T: Decodable>(_ request: URLRequest) async throws -> T {
    do {
        let (data, response) = try await session.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        guard 200..<300 ~= httpResponse.statusCode else {
            throw NetworkError.serverError(statusCode: httpResponse.statusCode)
        }

        guard let data = data, !data.isEmpty else {
            throw NetworkError.noData
        }

        do {
            return try JSONDecoder().decode(T.self, from: data)
        } catch {
            throw NetworkError.decodingError(error)
        }
    } catch {
        throw NetworkError.underlying(error)
    }
}

🧪 Bonus: Decode API Error Messages (When Servers are Nice)

Some APIs return error payloads like this:

{
  "error": {
    "message": "Invalid token",
    "code": 401
  }
}

You can optionally decode and surface these messages:

struct APIError: Decodable {
    let message: String
    let code: Int
}

🧼 Step 3: Handle Errors Elegantly at the UI Layer

do {
    let users = try await userService.fetchUsers()
    // update UI
} catch let error as NetworkError {
    switch error {
    case .serverError(let code):
        showToast("Server error: \(code)")
    case .decodingError:
        showToast("Data formatting issue.")
    default:
        showToast("Something went wrong.")
    }
} catch {
    showToast("Unexpected error: \(error.localizedDescription)")
}

This gives users clarity and developers a nice paper trail for debugging 🧾

👨‍🔧 Real Dev Tip

You’re now one enum away from chaos or clarity.

Having a well-defined error model makes your networking layer feel like a real system — not just an experiment that occasionally works.

Next up: let’s put all this into action and fetch some real-world data using our beautiful new network layer.

🌐 Real-World API Integration (Let’s Fetch Some Users!)

Now that we’ve built a clean and testable networking layer with proper error handling, let’s throw it into action.

Our mission: fetch a list of users from an API (classic, but timeless).

👤 Step 1: Define the Model

Let’s assume your backend returns this JSON:

[
  {
    "id": 1,
    "name": "Karan"
  },
  {
    "id": 2,
    "name": "SwiftBot"
  }
]

Your Swift model:

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

🔧 Step 2: Create a Service That Uses Our NetworkClient

final class UserService {
    private let networkClient: NetworkClient

    init(networkClient: NetworkClient) {
        self.networkClient = networkClient
    }

    func fetchUsers() async throws -> [User] {
        guard let url = URL(string: "https://api.example.com/users") else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"

        return try await networkClient.send(request)
    }
}

Clean, type-safe, and testable ✅

⚙️ Step 3: Use It in a ViewModel or Controller

class UserViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var errorMessage: String?

    private let userService: UserService

    init(userService: UserService) {
        self.userService = userService
    }

    @MainActor
    func loadUsers() async {
        do {
            users = try await userService.fetchUsers()
        } catch {
            errorMessage = "Failed to load users: \(error.localizedDescription)"
        }
    }
}
💡 We’re now working fully with async/await and injecting everything from the top — this is as Swift 2025 as it gets.

🔗 Related Read (Async/Await Deep Dive)

Curious how async/await works under the hood, or want to master cancellation and structured concurrency?

📘 Mastering Async/Await in Swift (Part 1)

**Mastering async/await in Swift: A Beginner’s Guide to Modern Concurrency (Part 1)** _Confused by Swift’s async/await? Learn the basics of async/await, how it improves your Swift code, and how to get…_medium.comhttps://medium.com/swift-pal/mastering-async-await-in-swift-a-beginners-guide-to-modern-concurrency-part-1-88cdb659ac3b

🧼 Clean Architecture Hint

If you’re following Clean Architecture or MVVM, this kind of separation:

And yes — if you haven’t already:

📘 Understanding Clean Architecture in iOS

**Understanding Clean Architecture in iOS: A Beginner’s Guide** _Tired of Spaghetti Code? 🍝 Let’s Clean Things Up in iOS!_medium.comhttps://medium.com/swift-pal/understanding-clean-architecture-in-ios-a-beginners-guide-69d09b4883c4

Next up: for the pros — let’s explore a few enhancements like retries, token refresh, and when not to go full NASA mode.

💡 Bonus — Retry Logic, Caching, and Token Refresh (For the Overachievers)

By now, we’ve got:

But real-world apps often need a little extra sauce 🌶️

Let’s talk about advanced yet practical additions you can layer on top.

🔁 1. Retry Logic (Without Writing Ugly Loops)

Sometimes a request fails because of flaky Wi-Fi or a momentary blip. Retrying smartly can save the day.

A simple retry wrapper with delay:

extension NetworkClient {
    func sendWithRetry<T: Decodable>(
        _ request: URLRequest,
        retries: Int = 2,
        delay: TimeInterval = 1
    ) async throws -> T {
        var lastError: Error?
        for attempt in 0...retries {
            do {
                return try await send(request)
            } catch {
                lastError = error
                try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
            }
        }
        throw lastError ?? NetworkError.underlying(NSError(domain: "", code: -999))
    }
}

💡 You can even retry only on .serverError, not on decoding or invalid URLs.

🔐 2. Token Refresh (OAuth-style)

If your APIs require bearer tokens, you’ll eventually hit a 401 Unauthorized. Time to refresh that token and retry.

Quick idea:

  1. Check if response is a 401.
  2. Pause the request.
  3. Trigger token refresh.
  4. Retry original request with new token.

Add logic inside send() to:

Warning: This adds complexity — isolate this into a decorator/service if it grows.

🧠 3. In-Memory Caching (Only if Needed)

For GET-heavy APIs (like product lists or static data), a lightweight in-memory cache can save bandwidth and feel snappy.

final class CachingNetworkClient: NetworkClient {
    private let wrapped: NetworkClient
    private var cache = [URLRequest: Data]()

    init(wrapping client: NetworkClient) {
        self.wrapped = client
    }

    func send<T: Decodable>(_ request: URLRequest) async throws -> T {
        if let cachedData = cache[request] {
            return try JSONDecoder().decode(T.self, from: cachedData)
        }
        let data: T = try await wrapped.send(request)
        let encoded = try JSONEncoder().encode(data)
        cache[request] = encoded
        return data
    }
}

Use this with caution. You don’t want to cache /checkout responses. 😅

🚫 When Not to Go Too Far

Sometimes, it’s okay to:

Your networking layer should feel like a helper, not a second job.

If you’re building a layered, modular app and wondering where to put these things — you’ll want to explore clean architecture boundaries.

👉 Understanding Clean Architecture in iOS

**Understanding Clean Architecture in iOS: A Beginner’s Guide** _Tired of Spaghetti Code? 🍝 Let’s Clean Things Up in iOS!_medium.comhttps://medium.com/swift-pal/understanding-clean-architecture-in-ios-a-beginners-guide-69d09b4883c4

One last section to go: let’s tie it all together and wrap this up beautifully.

✅ Wrapping Up — Clean Code Wins (Now and Forever)

If you’ve made it this far — give yourself a high-five 🙌

You didn’t just learn how to make API calls. You built a modular, testable, async/await-powered networking layer that:

And the best part? It’s pure Swift — no external dependencies, no Giga-frameworks, no regrets.

🛠️ You Now Have a Foundation That’s:

You’ve essentially built a tiny networking framework for your team… without becoming that developer who overengineered a GET request to look like a GraphQL mutation handler 😅

🔗 Want to Keep Learning?

If this felt good (and we both know it did 😉), here are a few articles from the same brain that can take you further:

👋 Closing Thoughts

In a world full of hacks and half-documented StackOverflow snippets, writing clean networking code is a rare flex. So flex it proudly 💪

And hey — if this helped you or made you chuckle (or both), consider giving it a 👏 or even subscribing for more no-nonsense iOS deep dives.

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading