Question 4
Repositories, use cases, and data boundaries
View models are calling URLSession directly and caching in static vars. How would you introduce a repository or use-case layer without over-engineering?
Follow-ups
- How do you handle offline vs online in one place?
Answer outline
A repository is the one type your feature code asks for data. The view model says 'give me the profile for this user' and doesn't care whether the answer comes from the network or a local store. It calls user(id:) on a UserRepository instead of scattering URLSession, JSONDecoder, and file I/O across every screen.
Behind that boundary you centralize the decisions that otherwise end up in static vars on each view model. The repository decides when to hit the network and how long a cached value stays valid, which is its time to live (TTL).
Keep it small. Start with one repository per area of data, backed by a protocol, one live implementation, and a fake for tests. Add a separate use-case layer only when the same multi-step operation shows up in more than one feature.
Principles
- A repository coordinates the API and the local store for one area of data without becoming either one.
- Keep
Codableresponse structs near the network layer, and map to domain types when the response shape and the app model diverge. - Make the repository an
actorwhen it holds a mutable cache shared across tasks. - Scope each repository to one area of data, such as
FeedRepository, and avoid aGodRepositorythat knows everything.
The view model only knows the UserRepository protocol:
final class ProfileViewModel {
private let users: UserRepository
init(users: UserRepository) { self.users = users }
func load(id: String) async throws {
let user = try await users.user(id: id)
// Format for display. No URLSession here.
}
}
The live implementation calls URLSession, decodes the response struct, and maps it to the domain type, while a test substitutes a fake:
protocol UserRepository {
func user(id: String) async throws -> User
}
final class LiveUserRepository: UserRepository {
private let session: URLSession
private let baseURL: URL
init(session: URLSession = .shared, baseURL: URL) {
self.session = session
self.baseURL = baseURL
}
func user(id: String) async throws -> User {
let url = baseURL.appending(path: "users/\(id)")
let (data, _) = try await session.data(from: url)
let response = try JSONDecoder().decode(UserResponse.self, from: data)
return User(response) // map the response struct to the domain type
}
}
Follow-up angles
- For offline support, make the repository return the local copy immediately and refresh from the network in the background. It then publishes the fresh value through the same path.
- The Core Data or SwiftData stack usually lives below the repositories rather than inside SwiftUI views, so persistence details stay out of the feature code.



