← All topics/Architecture & design patterns

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 Codable response structs near the network layer, and map to domain types when the response shape and the app model diverge.
  • Make the repository an actor when it holds a mutable cache shared across tasks.
  • Scope each repository to one area of data, such as FeedRepository, and avoid a GodRepository that knows everything.

The view model only knows the UserRepository protocol:

View model depends on the repository 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 plus implementation (tests swap in 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.