The Best Patterns for Swift Networking Layers

Swift code on a MacBook screen with networking icons
Building a clean networking layer in Swift starts with clear architecture decisions. (Photo by Pexels)

Every iOS developer eventually hits that same wall. The networking layer sprawls across view controllers, tangles decoding with UI logic, and laughs at your unit tests. I’ve refactored enough of these messes to know the fix isn’t some library. It’s a pattern. In this article I’ll walk through three patterns I actually keep reaching for—from a lightweight router that gets small projects moving, to a repository-backed service for apps that need to scale. Each one targets specific pain points: compile-time safety, testability, and keeping concerns from bleeding into each other.

Why Most Networking Layers Break Down

Let’s name the failure modes first. The usual move—a singleton APIManager with a generic request(_:completion:) method—feels fine until endpoint number two shows up. Then you’re juggling string paths, switching on HTTP methods, and force-unwrapping stuff from JSONSerialization. The mess compounds quickly:

  • No type safety at the endpoint level. A typo in a path string compiles happily and crashes at runtime.
  • Decoding logic leaks everywhere. Every view model suddenly needs to know that User lives behind the "data" key.
  • Testing demands network stubs. The layer is glued to URLSession, so you can’t easily swap in a mock.

The patterns below hit these problems head-on. They lean on Swift features—enums with associated values, protocols with generics, Result types—to build layers that are explicit, decoupled, and genuinely testable.

Pattern 1: The Enum-Based Router

Close-up of Swift routing code in Xcode
Enum routers keep endpoint definitions centralized and type-safe. (Photo by Pexels)

For apps with fewer than a dozen endpoints, the enum router is my default. It crams every endpoint into a single type, so your whole API surface sits in one place. Here’s the skeleton:

enum APIRouter {
    case getUser(id: Int)
    case updateUser(id: Int, name: String)
    case listPosts(page: Int)

    var path: String {
        switch self {
        case .getUser(let id): return "/users/\(id)"
        case .updateUser(let id, _): return "/users/\(id)"
        case .listPosts: return "/posts"
        }
    }

    var method: HTTPMethod {
        switch self {
        case .getUser: return .get
        case .updateUser: return .put
        case .listPosts: return .get
        }
    }

    var parameters: Parameters? {
        switch self {
        case .getUser: return nil
        case .updateUser(_, let name): return ["name": name]
        case .listPosts(let page): return ["page": page]
        }
    }
}

This kills stringly-typed paths and forces every endpoint to declare its HTTP method and parameters. The router doesn’t fire off requests itself; it just produces the URL request bits that a separate client consumes. I pair it with a protocol:

protocol NetworkClient {
    func perform<T: Decodable>(_ route: APIRouter) async throws -> T
}

The concrete implementation uses URLSession and JSONDecoder, but because the client hides behind a protocol, unit tests can inject a mock that returns predefined JSON. The router enum also lends itself to snapshot testing—you can iterate over every case and verify the generated URLs haven’t drifted.

When to use it: Projects with a modest number of endpoints that share a single base URL and auth scheme. The enum router shines in early development when the API contract is still shifting—adding a case is a one-line change the compiler checks exhaustively.

Pattern 2: The Service Protocol with Generic Endpoints

Once endpoint count creeps past twenty, the enum router gets bloated. The switch statements balloon, and you start mixing concerns—user endpoints sitting next to payment endpoints in the same file. The service protocol pattern splits the API by domain and introduces a reusable endpoint descriptor.

Define a generic Endpoint struct that holds the path, method, parameters, and response type:

struct Endpoint<Response: Decodable> {
    let path: String
    let method: HTTPMethod
    let parameters: Parameters?
    let decoder: JSONDecoder
}

Then create a protocol for each domain service. A UserService might look like this:

protocol UserService {
    func fetchUser(id: Int) async throws -> User
    func updateUser(id: Int, name: String) async throws -> User
}

final class RemoteUserService: UserService {
    private let client: NetworkClient

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

    func fetchUser(id: Int) async throws -> User {
        let endpoint = Endpoint<User>(
            path: "/users/\(id)",
            method: .get,
            parameters: nil,
            decoder: JSONDecoder()
        )
        return try await client.perform(endpoint)
    }

    func updateUser(id: Int, name: String) async throws -> User {
        let endpoint = Endpoint<User>(
            path: "/users/\(id)",
            method: .put,
            parameters: ["name": name],
            decoder: JSONDecoder()
        )
        return try await client.perform(endpoint)
    }
}

This gives you clear ownership boundaries. The UserService knows about user endpoints and nothing else. If the team grows, different developers can own different services without merge conflicts in a monolithic router file. The Endpoint struct also makes it easy to attach endpoint-specific decoding strategies—some responses might need a custom JSONDecoder with keyDecodingStrategy set to .convertFromSnakeCase, others might use a different DateDecodingStrategy.

Testing advantage: Because the service depends on a NetworkClient protocol, you can test the service logic without a real network. A mock client can simulate rate limiting, server errors, or empty responses, letting you verify the service’s error handling under controlled conditions.

Pattern 3: The Repository-Backed Service

Swift architecture diagram on a whiteboard
Repository patterns add a caching layer between the service and the view model. (Photo by Pexels)

When an app needs offline support or aggressive caching, I slide a repository layer between the service and the view model. The repository owns the decision of where to fetch data: from a local store, from the network, or both. This pattern builds on the service protocol above but adds a local persistence mechanism.

The repository protocol:

protocol UserRepository {
    func getUser(id: Int) async throws -> User
    func saveUser(_ user: User) async throws
}

A concrete implementation might use Core Data or a lightweight store like an NSCache-backed dictionary:

final class CachedUserRepository: UserRepository {
    private let remoteService: UserService
    private let localStore: UserLocalStore

    init(remoteService: UserService, localStore: UserLocalStore) {
        self.remoteService = remoteService
        self.localStore = localStore
    }

    func getUser(id: Int) async throws -> User {
        if let cached = try? await localStore.user(id: id) {
            return cached
        }
        let remoteUser = try await remoteService.fetchUser(id: id)
        try? await localStore.save(user: remoteUser)
        return remoteUser
    }
}

The repository is the single place that decides on the fetch strategy. View models never know whether the data came from a local cache or a network call—they just call repository.getUser(id:). This decoupling makes it easy to change caching policies later. You can add a time-to-live check, sync on background threads, or preload data based on navigation patterns—all without touching the view layer.

When to scale to this pattern: The repository pattern adds real complexity, so I only suggest it when you have a concrete reason: offline requirements, frequently accessed data that rarely changes, or a need to cut network calls for cost or performance reasons. If your app is always-online and the API is snappy, the service protocol pattern does the job.

Choosing the Right Pattern for Your App

There’s no single best pattern—just the one that fits your current constraints. I use a simple rule of thumb:

  1. Under 10 endpoints, single developer: Enum router. It’s fast to write, easy to read, and the compiler has your back when endpoints change.
  2. 10-30 endpoints, team of 2+: Service protocols with generic endpoints. The domain separation stops one file from turning into a bottleneck.
  3. Offline support or heavy caching needed: Repository-backed services. The extra layer isolates caching logic from both the network and the UI.

All three patterns share a common thread: they push dependency to the edges. The core networking client always sits behind a protocol, so tests don’t need a real network. Response types are always Decodable structs, so decoding is a single, predictable step. And the call sites—whether view models or coordinators—deal only with domain types, never with raw JSON or HTTP status codes.

Common Pitfalls in Swift Networking Layers

Even with a solid pattern, a few mistakes can undercut the design. Here are the ones I run into most:

Over-abstracting the Network Client

It’s tempting to build a client that handles authentication, retries, logging, and response validation all in one class. Don’t. Each of those concerns belongs in a separate component that composes with the client. For instance, an authentication interceptor can be a middleware that tweaks the request before the client sends it. That keeps the client itself focused on a single job: executing a request and returning data.

Ignoring Error Hierarchies

A networking layer that throws only Error or NSError forces every consumer to pick domain-specific failures out of generic types. Define a clear error enum:

enum NetworkError: Error {
    case invalidURL
    case unauthorized
    case serverError(statusCode: Int)
    case decodingFailed(DecodingError)
    case noConnection
}

Map URLSession errors and HTTP status codes to these cases in one spot. View models can then switch on the specific error and show appropriate UI—a retry button for .noConnection, a login prompt for .unauthorized.

Tight Coupling to a Single Decoder

Not every endpoint returns the same JSON shape. Some APIs wrap responses in a "data" envelope; others don’t. If your client assumes one JSONDecoder configuration, you’ll end up writing workaround code in the services. Instead, let each endpoint carry its own decoder, as shown in the Endpoint struct above. This keeps decoding logic declarative and endpoint-specific.

FAQ

Which pattern works best with SwiftUI and Combine?

All three patterns fit fine with SwiftUI and Combine because they produce asynchronous results you can wrap in @Published properties or ObservableObject view models. With the service and repository patterns, I usually expose async throws methods and call them from .task modifiers. If you need Combine publishers, the service can return AnyPublisher by wrapping the async call with a Future. The pattern itself doesn’t dictate the reactive framework—it stays at the data layer and leaves the presentation binding to the view model.

How do I handle authentication tokens in these patterns?

Authentication should be invisible to the service and repository layers. I suggest an interceptor or middleware approach: a component that conforms to NetworkClient and wraps the real client. The interceptor pulls the token from a secure store, attaches it to the request, and refreshes it if a 401 response comes back. The service calls the interceptor as if it were the client, so no service code changes when the auth flow evolves.

Can I mix patterns within the same app?

Yes, and I often do. An app might use an enum router for a small set of configuration endpoints while using service protocols for the main user and content APIs. Consistency within a domain is what matters: if the user-related endpoints use a service protocol, all user endpoints should go through that service. Mixing patterns inside a domain creates confusion about where to add new endpoints and how to test them.

What about third-party networking libraries like Alamofire?

I avoid tying the core pattern to a specific library. Instead, the NetworkClient protocol acts as a boundary. The concrete implementation can use URLSession directly, or it can wrap Alamofire or another library. The important part is that the rest of the app depends only on the protocol, so swapping the implementation is a single-file change. This also makes it easy to write tests that don’t import the networking library at all.

Moving Forward with a Clean Networking Layer

The patterns I’ve described aren’t theoretical—they come from shipping apps that had to work on spotty connections, with evolving APIs, and across teams of different sizes. Start with the enum router if you’re building something new. Extract service protocols when the router gets too large. Add a repository only when caching becomes a real requirement. Each step is a straightforward refactor because the core principle stays the same: keep the network details behind a protocol, and let the rest of the app work with domain types.

A well-structured networking layer pays off every time you write a test, onboard a new team member, or need to change the backend contract. It’s one of those investments that feels like overhead on day one and becomes indispensable by week three.