Skip to content

Networking flow

How a request travels from a ViewModel through APIClient and back, and where to plug in auth, logging, and decoding.

This page traces a single request end to end: what the ViewModel calls, how the service builds the request, what the client does with it, and how the response becomes a domain model or an AppError.

  • APIRequest<Response>: a value describing a single request, generic over the type it decodes to.
  • APIClient: a URLSession-backed client that sends an APIRequest, runs interceptors, and decodes the response.
  • Interceptor: a small protocol with two hooks, prepare(_:) and intercept(_:_:). Composed in a chain.
  • AuthInterceptor: injects the current token from TokenStore into the Authorization header.
  • LoggingInterceptor: in DEBUG, logs request and response (with optional bodies) via os.Logger.
  • APIConfig: per-environment base URL, default headers, and auth strategy.
  • AppError: the single error type that flows out of the client.
flowchart LR
    VM[ViewModel] -->|"await service.fetch()"| S[Service]
    S -->|"APIRequest&lt;[Movie]&gt;"| C[APIClient]
    C -->|"prepare()"| I1[AuthInterceptor]
    I1 -->|"prepare()"| I2[LoggingInterceptor]
    I2 -->|"URLRequest"| US[URLSession]
    US -->|"data, response"| I2
    I2 -->|"intercept()"| I1
    I1 -->|"Decodable"| C
    C -->|"[Movie] / AppError"| S
    S -->|"loaded([Movie])"| VM

Each hop has a clear role:

  1. ViewModel calls a service.fetch() (or create, update, etc.), awaits the result, and updates its observable state.
  2. Service constructs an APIRequest<[Movie]> describing the path, method, query params, and body, then asks the client to send it.
  3. APIClient runs the interceptor chain in order:
    • prepare(URLRequest) -> URLRequest: each interceptor mutates the outgoing request. AuthInterceptor adds the auth header. LoggingInterceptor logs the outgoing request.
  4. URLSession executes the request.
  5. APIClient runs the chain in reverse for the response:
    • intercept(Response) throws -> Response: each interceptor sees the response. LoggingInterceptor logs status + body. Errors thrown here become AppError.
  6. APIClient decodes the Data into the request’s Response type via JSONDecoder. Decode failures map to AppError.decodingFailed(_).
  7. Service maps any wire-level types into domain models (often the Decodable is already the domain model). Returns to the ViewModel.
  8. ViewModel transitions its state (.loading -> .loaded(value) on success, .failed(error) on AppError).

In the service implementation:

Services/MovieServiceImpl.swift
func fetch() async throws -> [Movie] {
let request = APIRequest<MoviePage>(
path: "/movie/popular",
method: .get,
query: ["language": "en-US", "page": "1"]
)
let page = try await client.send(request)
return page.results
}

APIRequest is generic; client.send(_:) returns the decoded Response directly. The service unwraps the API’s envelope (MoviePage here) and returns just the part the rest of the app cares about.

AuthInterceptor reads from a TokenStore actor. By default the store is in-memory; replace its body with Keychain access for production:

Infrastructure/Auth/TokenStore.swift
actor TokenStore {
private var token: String?
func current() -> String? { token }
func set(_ newToken: String?) { token = newToken }
}

APIConfig decides which auth strategy to apply: Bearer <token>, Basic <base64>, custom header, or none. The interceptor consults the config and applies it without the service needing to know.

In DEBUG, LoggingInterceptor logs every request and response through os.Logger with category:network. There are four toggles for noisy projects:

Infrastructure/Networking/LoggingInterceptor.swift
enum LogConfig {
static var requests: Bool = true
static var requestBodies: Bool = true
static var responses: Bool = true
static var responseBodies: Bool = true
}

In Release builds, the interceptor is a no-op.

To read the trace, open Console.app, filter by subsystem:<your-bundle-id> and category:network. You’ll see one line per request and one per response, with the URL, status, and (optionally) bodies.

Every failure becomes an AppError. The common cases:

CauseMaps to
URLError from URLSession.transport(URLError)
Non-2xx status.server(status: Int, body: Data?)
Body fails to decode into Response.decodingFailed(DecodingError)
Interceptor throwswhatever the interceptor threw
Cancelled task.cancelled

ViewModels switch on AppError to show a sensible message. The point is that nothing above the service layer ever sees raw URLError or DecodingError.

Services are protocols, and every feature has a Mock<Name>Service. In tests:

let mock = MockMovieService()
mock.fetchResult = .success([Movie.fixture])
let vm = MovieListViewModel(service: mock)
await vm.load()
XCTAssertEqual(vm.state, .loaded([Movie.fixture]))

In SwiftUI previews:

#Preview {
MovieListView(viewModel: MovieListViewModel(service: MockMovieService()))
}

The mock and the real impl share a protocol, so the rest of the app doesn’t notice the swap.