Construyendo una Pokédex en iOS — Parte 3: DTOs y el contrato con la API
Parte 3 de la serie PokeTracker: qué son los DTOs, por qué no usamos los datos de la API directamente y cómo definimos el contrato con PokéAPI.
En el post anterior construimos el cliente HTTP. Ahora necesitamos los tipos que va a decodificar. Antes de escribir código conviene responder una pregunta que aparece seguido en proyectos iOS: ¿para qué necesitamos DTOs si ya podemos hacer structs Decodable y usarlos directamente como modelos de dominio?
Por qué no usamos el JSON directamente
Imagina que la PokéAPI devuelve esto para un Pokémon de la lista:
{
"name": "bulbasaur",
"url": "https://pokeapi.co/api/v2/pokemon/1/"
}
La respuesta no incluye el ID ni la URL del sprite. El ID está embebido en la URL del recurso. El sprite vive en otro servidor (raw.githubusercontent.com). Si haces un struct Decodable con id e imageURL, el decoder va a fallar porque esos campos no existen en el JSON.
La solución obvia es hacer el struct que sí refleja lo que llega: name y url. Y de ahí derivar el ID y la URL del sprite. Eso es un DTO: un tipo cuya única responsabilidad es mapear lo que devuelve la API.
Si luego la PokéAPI cambia su formato o tú cambias de proveedor de sprites, solo tocas el DTO. El resto del código — ViewModels, vistas, lógica de negocio — no se entera.
URL+Extensions
Antes de los DTOs necesitamos una extensión. Crea Core/Extensions/URL+Extensions.swift:
import Foundation
extension URL {
var pokemonID: Int? {
let components = pathComponents
guard let last = components.last, let id = Int(last) else { return nil }
return id
}
}
La PokéAPI incluye siempre una URL como https://pokeapi.co/api/v2/pokemon/1/. El ID es el último componente del path. Esta extensión lo extrae.
PokemonListDTO
Crea Features/Pokemon/Data/DTOs/PokemonListDTO.swift:
import Foundation
struct PokemonListDTO: Decodable, Sendable {
let count: Int
let next: String?
let previous: String?
let results: [PokemonListItemDTO]
}
struct PokemonListItemDTO: Decodable, Sendable {
let name: String
let url: String
var id: Int? {
URL(string: url)?.pokemonID
}
var spriteURL: URL? {
guard let id = id else { return nil }
return URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(id).png")
}
}
PokemonListDTO mapea exactamente lo que devuelve el endpoint /pokemon. El campo next es opcional porque en la última página no existe.
PokemonListItemDTO tiene solo dos campos que vienen del JSON — name y url. Las propiedades id y spriteURL se derivan de esos campos: son computadas, no almacenadas, y no afectan al Decodable.
Esta separación hace que el DTO tenga exactamente la forma del JSON, sin más. No hay propiedades opcionales artificiales ni lógica de negocio metida en el DTO.
PokemonDetailDTO
La respuesta del endpoint de detalle es mucho más rica. Crea Features/Pokemon/Data/DTOs/PokemonDetailDTO.swift:
import Foundation
struct PokemonDetailDTO: Decodable, Sendable {
let id: Int
let name: String
let baseExperience: Int?
let height: Int
let weight: Int
let sprites: SpritesDTO
let stats: [StatEntryDTO]
let types: [TypeEntryDTO]
let abilities: [AbilityEntryDTO]
let moves: [MoveEntryDTO]
struct SpritesDTO: Decodable, Sendable {
let frontDefault: String?
let other: OtherSpritesDTO?
struct OtherSpritesDTO: Decodable, Sendable {
let officialArtwork: OfficialArtworkDTO?
enum CodingKeys: String, CodingKey {
case officialArtwork = "official-artwork"
}
struct OfficialArtworkDTO: Decodable, Sendable {
let frontDefault: String?
}
}
}
struct StatEntryDTO: Decodable, Sendable {
let baseStat: Int
let stat: NamedResourceDTO
}
struct TypeEntryDTO: Decodable, Sendable {
let slot: Int
let type: NamedResourceDTO
}
struct AbilityEntryDTO: Decodable, Sendable {
let ability: NamedResourceDTO
let isHidden: Bool
}
struct MoveEntryDTO: Decodable, Sendable {
let move: NamedResourceDTO
}
}
struct NamedResourceDTO: Decodable, Sendable {
let name: String
let url: String
}
NamedResourceDTO aparece en varios lugares de la API: tipos, habilidades, movimientos. La PokéAPI siempre usa el mismo patrón { name, url } para referenciar recursos. Un solo struct reutilizable cubre todos esos casos.
El caso de OfficialArtworkDTO ilustra por qué a veces sí necesitamos CodingKeys manuales: la clave en el JSON es "official-artwork" con guión, que no es un identificador Swift válido. Con .convertFromSnakeCase eso no se resuelve automáticamente.
PokemonSpeciesDTO y EvolutionChainDTO
Crea Features/Pokemon/Data/DTOs/PokemonSpeciesDTO.swift:
import Foundation
struct PokemonSpeciesDTO: Decodable, Sendable {
let id: Int
let name: String
let flavorTextEntries: [FlavorTextEntryDTO]
let genera: [GenusEntryDTO]
let evolutionChain: EvolutionChainReferenceDTO
var englishFlavorText: String? {
flavorTextEntries
.first { $0.language.name == "en" }?
.flavorText
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\u{000C}", with: " ")
}
var englishGenus: String? {
genera.first { $0.language.name == "en" }?.genus
}
struct FlavorTextEntryDTO: Decodable, Sendable {
let flavorText: String
let language: NamedResourceDTO
}
struct GenusEntryDTO: Decodable, Sendable {
let genus: String
let language: NamedResourceDTO
}
struct EvolutionChainReferenceDTO: Decodable, Sendable {
let url: String
var id: Int? {
URL(string: url)?.pokemonID
}
}
}
Las propiedades computadas englishFlavorText y englishGenus son filtros sobre los arrays del DTO. La PokéAPI devuelve las descripciones en todos los idiomas disponibles — elegimos el inglés aquí mismo. También limpiamos los caracteres de salto de página (\u{000C}) que aparecen en algunos textos del juego.
Crea también Features/Pokemon/Data/DTOs/EvolutionChainDTO.swift:
import Foundation
struct EvolutionChainDTO: Decodable, Sendable {
let id: Int
let chain: ChainLinkDTO
struct ChainLinkDTO: Decodable, Sendable {
let species: NamedResourceDTO
let evolvesTo: [ChainLinkDTO]
}
}
La cadena de evolución es recursiva en la API: cada nodo tiene un evolvesTo que puede contener más nodos. El struct ChainLinkDTO se referencia a sí mismo, lo que es perfectamente válido en Swift.
Qué viene en el siguiente post
Con los DTOs listos, en el Post 4 construimos el Repository: el componente que usa el cliente HTTP para hacer las llamadas, recibe los DTOs y los expone hacia el resto de la app.