← Volver al blog

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.