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.
The pieces
Section titled “The pieces”APIRequest<Response>: a value describing a single request, generic over the type it decodes to.APIClient: aURLSession-backed client that sends anAPIRequest, runs interceptors, and decodes the response.Interceptor: a small protocol with two hooks,prepare(_:)andintercept(_:_:). Composed in a chain.AuthInterceptor: injects the current token fromTokenStoreinto theAuthorizationheader.LoggingInterceptor: in DEBUG, logs request and response (with optional bodies) viaos.Logger.APIConfig: per-environment base URL, default headers, and auth strategy.AppError: the single error type that flows out of the client.
Request flow
Section titled “Request flow”flowchart LR
VM[ViewModel] -->|"await service.fetch()"| S[Service]
S -->|"APIRequest<[Movie]>"| 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:
- ViewModel calls a
service.fetch()(orcreate,update, etc.),awaits the result, and updates its observable state. - Service constructs an
APIRequest<[Movie]>describing the path, method, query params, and body, then asks the client to send it. - APIClient runs the interceptor chain in order:
prepare(URLRequest) -> URLRequest: each interceptor mutates the outgoing request.AuthInterceptoradds the auth header.LoggingInterceptorlogs the outgoing request.
- URLSession executes the request.
- APIClient runs the chain in reverse for the response:
intercept(Response) throws -> Response: each interceptor sees the response.LoggingInterceptorlogs status + body. Errors thrown here becomeAppError.
- APIClient decodes the
Datainto the request’sResponsetype viaJSONDecoder. Decode failures map toAppError.decodingFailed(_). - Service maps any wire-level types into domain models (often the
Decodableis already the domain model). Returns to the ViewModel. - ViewModel transitions its state (
.loading -> .loaded(value)on success,.failed(error)onAppError).
Building an APIRequest
Section titled “Building an APIRequest”In the service implementation:
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:
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.
Logging
Section titled “Logging”In DEBUG, LoggingInterceptor logs every request and response through os.Logger with category:network. There are four toggles for noisy projects:
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.
Errors
Section titled “Errors”Every failure becomes an AppError. The common cases:
| Cause | Maps to |
|---|---|
URLError from URLSession | .transport(URLError) |
| Non-2xx status | .server(status: Int, body: Data?) |
Body fails to decode into Response | .decodingFailed(DecodingError) |
| Interceptor throws | whatever 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.
Tests and previews
Section titled “Tests and previews”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.
See also
Section titled “See also”- Architecture
- Routing flow
- DI
generate servicegenerate apifor OpenAPI-driven clients.