A reducer that depends separately on URLSession, SwiftData, a cache, and authentication knows too much about how data is assembled.
I expose one capability-oriented client:
struct ReportsClient: Sendable {
var loadReports: @Sendable () async throws(AppError) -> [Report]
var refresh: @Sendable () async throws(AppError) -> [Report]
var delete: @Sendable (Report.ID) async throws(AppError) -> Void
}
Its live implementation may read a cache immediately, persist API results, and fall back to stored data. The reducer asks for reports; it does not coordinate storage technologies.
@Dependency(\.reportsClient) var reportsClient
Tests replace the same boundary with deterministic closures. They can return fixtures, suspend, fail, or record calls without initializing SwiftData or URLSession.
The client should not become a duplicate of every backend endpoint. Name operations after feature capabilities and return domain-friendly values. Transport DTOs and cache keys stay inside Data.
One boundary does not mean one enormous client for the whole app. Smaller feature clients clarify ownership and keep test setup focused. Shared lower-level primitives can still power their live implementations.
This pattern also makes offline policy explicit. loadReports can define cache-first behavior; refresh can require the network. The reducer chooses intent while the client owns mechanics.