Building a Pokédex on iOS — Part 4: Repository Pattern
Part 4 of the PokeTracker series: what the Repository Pattern is, why we use it, and how we implement PokemonRepository.
We have the HTTP client and the DTOs. The next step is the Repository. It’s the pattern that generates the most questions when someone sees it for the first time, because it looks like an extra layer with no apparent reason.
The problem it solves
Without a Repository, the ViewModel makes network calls directly:
// Without Repository — the ViewModel knows where the data lives
func loadPokemons() async {
let url = URL(string: "https://pokeapi.co/api/v2/pokemon?limit=20")!
let (data, _) = try await URLSession.shared.data(from: url)
let dto = try JSONDecoder().decode(PokemonListDTO.self, from: data)
// ...
}
This has several problems:
- The ViewModel knows the data comes from an HTTP URL. If tomorrow the data comes from a local database or a local JSON file, you have to rewrite the ViewModel.
- There’s no clean way to test the ViewModel without making real network calls.
- The logic of which endpoint to call, with what parameters, and how to map the response is mixed in with the presentation logic.
The Repository solves all of this: it’s the only class that knows where the data comes from. The ViewModel simply asks it “give me the list of Pokémon” without knowing whether that involves an HTTP call, a SwiftData query, or a local file.
The protocol
Create Features/Pokemon/Data/Repository/PokemonRepository.swift. We start with the protocol:
import Foundation
protocol PokemonRepositoryProtocol: Sendable {
func fetchPokemonList(limit: Int, offset: Int) async throws -> PokemonListDTO
func fetchPokemonDetail(idOrName: String) async throws -> PokemonDetailDTO
func fetchPokemonSpecies(idOrName: String) async throws -> PokemonSpeciesDTO
func fetchEvolutionChain(id: Int) async throws -> EvolutionChainDTO
}
The protocol defines what a Repository can do, not how it does it. Whoever implements this protocol can make HTTP calls, read files, or generate in-memory data — the rest of the code doesn’t know which.
Conforming to Sendable lets you use the Repository from concurrency contexts without compiler warnings.
The implementation
final class PokemonRepository: PokemonRepositoryProtocol, Sendable {
private let apiClient: APIClientProtocol
init(apiClient: APIClientProtocol = APIClient()) {
self.apiClient = apiClient
}
func fetchPokemonList(limit: Int, offset: Int) async throws -> PokemonListDTO {
let endpoint = Endpoint.pokemonList(limit: limit, offset: offset)
return try await apiClient.request(endpoint)
}
func fetchPokemonDetail(idOrName: String) async throws -> PokemonDetailDTO {
let endpoint = Endpoint.pokemonDetail(idOrName: idOrName.lowercased())
return try await apiClient.request(endpoint)
}
func fetchPokemonSpecies(idOrName: String) async throws -> PokemonSpeciesDTO {
let endpoint = Endpoint.pokemonSpecies(idOrName: idOrName.lowercased())
return try await apiClient.request(endpoint)
}
func fetchEvolutionChain(id: Int) async throws -> EvolutionChainDTO {
let endpoint = Endpoint.evolutionChain(id: id)
return try await apiClient.request(endpoint)
}
}
The Repository accepts an APIClientProtocol in its initializer. In production it receives a real APIClient. In tests it receives a MockAPIClient that returns predefined data. This is dependency injection: instead of creating dependencies inside the class, we pass them in from outside.
The default value APIClient() is convenient: anyone who instantiates PokemonRepository() without arguments gets the real client automatically.
Why the methods return DTOs instead of domain models
It might seem odd that the Repository returns PokemonListDTO instead of [Pokemon]. The reason is that the Repository belongs to the Data layer and domain models belong to Domain. The conversion from DTO to domain model happens in the Use Cases, one level up.
This keeps responsibilities separate: the Repository knows how to fetch data, the Use Cases know what to do with it.
What’s coming in the next post
In Post 5 we build the domain models and Use Cases: the app’s own types that don’t depend on any API, and the logic that converts DTOs into those types.