Construyendo una Pokédex en iOS — Parte 4: Repository Pattern
Parte 4 de la serie PokeTracker: qué es el Repository Pattern, por qué lo usamos y cómo implementamos PokemonRepository.
Tenemos el cliente HTTP y los DTOs. El siguiente paso es el Repository. Es el patrón que más preguntas genera cuando alguien lo ve por primera vez, porque parece una capa extra sin razón aparente.
El problema que resuelve
Sin un Repository, el ViewModel hace las llamadas a red directamente:
// Sin Repository — el ViewModel sabe dónde viven los datos
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)
// ...
}
Esto tiene varios problemas:
- El ViewModel sabe que los datos vienen de una URL HTTP. Si mañana los datos vienen de una base de datos local o de un archivo JSON local, hay que reescribir el ViewModel.
- Para probar el ViewModel sin hacer llamadas reales a la red, no hay forma limpia de hacerlo.
- La lógica de qué endpoint llamar, con qué parámetros, y cómo mapear la respuesta está mezclada con la lógica de presentación.
El Repository resuelve todo esto: es la única clase que sabe de dónde vienen los datos. El ViewModel solo le pide “dame la lista de Pokémon” sin saber si eso implica una llamada HTTP, una consulta a SwiftData, o un archivo local.
El protocolo
Crea Features/Pokemon/Data/Repository/PokemonRepository.swift. Empezamos por el protocolo:
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
}
El protocolo define qué puede hacer un Repository, no cómo lo hace. Quien implementa este protocolo puede hacer llamadas HTTP, leer archivos, generar datos en memoria — el resto del código no lo sabe.
Conformar con Sendable permite usar el Repository desde contextos de concurrencia sin advertencias del compilador.
La implementación
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)
}
}
El Repository acepta un APIClientProtocol en su inicializador. En producción recibe un APIClient real. En los tests recibe un MockAPIClient que devuelve datos predefinidos. Esto es inyección de dependencias: en lugar de crear las dependencias dentro de la clase, se las pasamos desde fuera.
El valor por defecto APIClient() es conveniente: quien instancia PokemonRepository() sin argumentos obtiene el cliente real automáticamente.
Por qué los métodos devuelven DTOs y no modelos de dominio
Puede parecer raro que el Repository devuelva PokemonListDTO en vez de [Pokemon]. La razón es que el Repository pertenece a la capa Data y los modelos de dominio pertenecen a Domain. La conversión de DTO a modelo de dominio ocurre en los Use Cases, un nivel más arriba.
Esto mantiene las responsabilidades separadas: el Repository sabe cómo obtener datos, los Use Cases saben qué hacer con ellos.
Qué viene en el siguiente post
En el Post 5 construimos los modelos de dominio y los Use Cases: los tipos propios de la app que no dependen de ninguna API, y la lógica que convierte los DTOs en esos tipos.