Architecture With Nico

Practical software architecture and design for iOS engineers.

The Repository Pattern in Swift: Useful Abstraction or Fancy Wrapper?

The Repository pattern can be useful. It can also become an expensive way to rename your API client.

The difference is whether the repository gives the rest of the app a better data boundary.

If a repository hides persistence details, coordinates cache and network, maps transport models into domain models, and gives features a stable way to ask for data, it is doing real work.

If it only forwards every method to URLSession, it may just be a fancy wrapper.

#What a Repository Should Represent

A repository should represent a collection-like boundary for a concept in your app.

protocol ProductsRepository {
    func products() async throws -> [Product]
    func product(id: Product.ID) async throws -> Product
    func save(_ products: [Product]) async throws
}

The rest of the app should not need to know whether products come from the network, a local database, a cache, a file, or some combination of them.

The repository is not the data source. It is the app-facing boundary around data access.

#A Repository Is Not Automatically Clean

This is not very useful:

final class ProductsRepository {
    private let apiClient: APIClient

    func getProducts() async throws -> ProductsResponse {
        try await apiClient.getProducts()
    }
}

The repository exposes transport language (ProductsResponse) and forwards directly to the API client. It does not protect the app from API shape, persistence details, or mapping concerns.

A better repository returns app-level models:

final class RemoteProductsRepository: ProductsRepository {
    private let client: HTTPClient

    func products() async throws -> [Product] {
        let dto: [ProductDTO] = try await client.get("/products")
        return dto.map(Product.init)
    }
}

Now the feature depends on Product, not ProductDTO. If the API response changes, the mapping changes inside the repository instead of leaking through the app.

#One Repository or Many?

A common mistake is creating one giant repository.

final class AppRepository {
    func getProducts() async throws -> [Product]
    func getUser() async throws -> User
    func getOrders() async throws -> [Order]
    func updateSettings(_ settings: Settings) async throws
}

This becomes a service locator with a nicer name.

Prefer repositories that follow meaningful domain boundaries:

protocol ProductsRepository { ... }
protocol UserRepository { ... }
protocol OrdersRepository { ... }

The boundary should match how the app thinks about data, not how the backend happens to group endpoints.

#Repositories and Caching

Repositories become especially useful when data comes from more than one place.

final class CachedProductsRepository: ProductsRepository {
    private let remote: ProductsRepository
    private let local: ProductsRepository

    func products() async throws -> [Product] {
        do {
            let products = try await remote.products()
            try await local.save(products)
            return products
        } catch {
            return try await local.products()
        }
    }
}

The ViewModel does not need to know about fallback behavior. The use case can ask for products and report a domain output. The repository boundary owns the data policy.

This is also a nice use of composition. The cache behavior wraps two repositories instead of being scattered through screens.

#Repositories, Use Cases, and Presentation

Should a feature trigger a repository directly or go through a use case?

It depends.

For a small read-only feature, a feature adapter can trigger a repository and pass the result to a presentation output:

final class ProductsFeatureAdapter {
    private let repository: ProductsRepository
    private let output: ProductsOutput

    func loadProducts() async {
        do {
            output.didLoadProducts(try await repository.products())
        } catch {
            output.didFailLoadingProducts()
        }
    }
}

For a feature with application rules, a use case often gives a better boundary:

final class CheckoutFeatureAdapter {
    private let placeOrder: PlaceOrderUseCase
    private let output: CheckoutOutput

    func submitOrder() async {
        await placeOrder.execute(output: output)
    }
}

The repository answers data questions. The use case performs application actions. The ViewModel maps outputs into state.

That distinction matters because repositories answer data questions, while use cases name application operations.

#Avoid Protocols That Only Mirror One Class

This shape is common:

protocol ProductsRepositoryProtocol {
    func getProducts() async throws -> [Product]
}

final class ProductsRepositoryImpl: ProductsRepositoryProtocol {
    func getProducts() async throws -> [Product] {
        // ...
    }
}

The protocol name and implementation name are both weak. The protocol says "I am a protocol." The implementation says "I am an implementation."

Better names describe roles:

protocol ProductsRepository {
    func products() async throws -> [Product]
}

final class RemoteProductsRepository: ProductsRepository { ... }
final class CachedProductsRepository: ProductsRepository { ... }
final class InMemoryProductsRepository: ProductsRepository { ... }

This makes the architecture easier to read. The names tell you what each piece contributes.

This is the same naming pressure discussed in Naming Protocols and Implementations in Swift.

#Repository Tests

Repository tests should focus on the boundary promise.

For a remote repository, you might test mapping and request construction with a stubbed HTTP client.

For a cached repository, you might test fallback behavior.

func test_products_returnsLocalProductsWhenRemoteFails() async throws {
    let local = InMemoryProductsRepository(products: [.keyboard])
    let remote = FailingProductsRepository()
    let repository = CachedProductsRepository(
        remote: remote,
        local: local
    )

    let products = try await repository.products()

    XCTAssertEqual(products, [.keyboard])
}

This test protects a policy the app cares about. It does not care how the local repository stores data internally.

#What You Should Remember

A repository is useful when it gives the app a stable data boundary. It should hide transport details, persistence details, mapping, caching, or data policy.

It is less useful when it only forwards calls to another object with different method names.

Do not add repositories because a diagram says every app needs them. Add them when they make data access easier to understand, easier to test, and easier to change.