← Volver al blog

Construyendo una Pokédex en iOS — Parte 5: Modelos de dominio y Use Cases

Parte 5 de la serie PokeTracker: creamos los modelos propios de la app y los Use Cases que convierten DTOs en datos listos para la interfaz.

Hasta ahora tenemos la red y los DTOs. Los DTOs reflejan lo que devuelve la API — son el lenguaje de la PokéAPI, no el lenguaje de nuestra app. Los modelos de dominio son los tipos propios de la app: Pokemon, PokemonDetail, PokemonType. No dependen de ninguna API, no tienen propiedades opcionales para “acomodar” el JSON, y llevan la lógica que tiene sentido para la interfaz.


Pokemon

Crea Features/Pokemon/Domain/Models/Pokemon.swift:

import Foundation

struct Pokemon: Identifiable, Hashable, Sendable {
    let id: Int
    let name: String
    let spriteURL: URL?

    var displayName: String {
        name.capitalized
    }

    var formattedID: String {
        String(format: "#%03d", id)
    }
}

extension Pokemon {
    init(from dto: PokemonListItemDTO) {
        self.id = dto.id ?? 0
        self.name = dto.name
        self.spriteURL = dto.spriteURL
    }
}

Pokemon es el modelo más simple de la app. La inicialización desde el DTO vive en una extensión para separar la lógica de mapeo del struct en sí.

formattedID convierte 1 en "#001" directamente en el modelo. No en la vista, no en el ViewModel: aquí, donde tiene sentido mantenerlo como comportamiento del modelo.


PokemonType

Crea Features/Pokemon/Domain/Models/PokemonType.swift. Los tipos de Pokémon tienen color e icono asociados — esa lógica vive en el dominio, no en la vista:

import SwiftUI

enum PokemonTypeEnum: String, CaseIterable, Sendable {
    case normal, fire, water, electric, grass, ice
    case fighting, poison, ground, flying, psychic, bug
    case rock, ghost, dragon, dark, steel, fairy
    case unknown

    var color: Color {
        switch self {
        case .normal:   return Color(hex: "A8A878")
        case .fire:     return Color(hex: "F08030")
        case .water:    return Color(hex: "6890F0")
        case .electric: return Color(hex: "F8D030")
        case .grass:    return Color(hex: "78C850")
        case .ice:      return Color(hex: "98D8D8")
        case .fighting: return Color(hex: "C03028")
        case .poison:   return Color(hex: "A040A0")
        case .ground:   return Color(hex: "E0C068")
        case .flying:   return Color(hex: "A890F0")
        case .psychic:  return Color(hex: "F85888")
        case .bug:      return Color(hex: "A8B820")
        case .rock:     return Color(hex: "B8A038")
        case .ghost:    return Color(hex: "705898")
        case .dragon:   return Color(hex: "7038F8")
        case .dark:     return Color(hex: "705848")
        case .steel:    return Color(hex: "B8B8D0")
        case .fairy:    return Color(hex: "EE99AC")
        case .unknown:  return Color.gray
        }
    }

    var icon: String {
        switch self {
        case .normal:   return "circle.fill"
        case .fire:     return "flame.fill"
        case .water:    return "drop.fill"
        case .electric: return "bolt.fill"
        case .grass:    return "leaf.fill"
        case .ice:      return "snowflake"
        case .fighting: return "figure.boxing"
        case .poison:   return "allergens"
        case .ground:   return "mountain.2.fill"
        case .flying:   return "wind"
        case .psychic:  return "eye.fill"
        case .bug:      return "ant.fill"
        case .rock:     return "fossil.shell.fill"
        case .ghost:    return "aqi.medium"
        case .dragon:   return "lizard.fill"
        case .dark:     return "moon.fill"
        case .steel:    return "shield.fill"
        case .fairy:    return "sparkles"
        case .unknown:  return "questionmark"
        }
    }
}

struct PokemonType: Identifiable, Sendable {
    var id: String { name }
    let name: String

    var typeEnum: PokemonTypeEnum {
        PokemonTypeEnum(rawValue: name.lowercased()) ?? .unknown
    }

    var displayName: String { name.capitalized }
    var color: Color { typeEnum.color }
    var icon: String { typeEnum.icon }
}

extension PokemonType {
    init(from dto: NamedResourceDTO) {
        self.name = dto.name
    }
}

extension Color {
    init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)
        let a, r, g, b: UInt64
        switch hex.count {
        case 3:  (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6:  (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8:  (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default: (a, r, g, b) = (255, 0, 0, 0)
        }
        self.init(.sRGB, red: Double(r)/255, green: Double(g)/255, blue: Double(b)/255, opacity: Double(a)/255)
    }
}

SwiftUI no tiene un inicializador nativo para strings hexadecimales, así que lo agregamos nosotros. El proceso tiene tres pasos: limpiar el string (quitar # o espacios si los hay), leer los bytes con Scanner, y construir el Color con los componentes RGB normalizados entre 0 y 1.

El switch sobre hex.count maneja los tres formatos que existen en la práctica: "F00" (3 dígitos, sin alpha), "FF0000" (6 dígitos, sin alpha) y "FF0000FF" (8 dígitos, con alpha). Para el formato de 3 dígitos, cada canal se expande multiplicando por 17 — equivalente a duplicar el dígito: FFF, 888.


PokemonDetail

Crea Features/Pokemon/Domain/Models/PokemonDetail.swift. Este modelo agrega información de dos endpoints distintos — detalle y especie:

import Foundation

struct PokemonDetail: Identifiable, Sendable {
    let id: Int
    let name: String
    let height: Int
    let weight: Int
    let baseExperience: Int
    let types: [PokemonType]
    let stats: [PokemonStat]
    let abilities: [PokemonAbility]
    let moves: [PokemonMove]
    let spriteURL: URL?
    let description: String?
    let genus: String?

    var displayName: String { name.capitalized }
    var formattedID: String { String(format: "#%03d", id) }
    var heightInMeters: String { String(format: "%.1f m", Double(height) / 10.0) }
    var weightInKg: String { String(format: "%.1f kg", Double(weight) / 10.0) }
    var primaryType: PokemonType? { types.first }
}

struct PokemonStat: Identifiable, Sendable {
    var id: String { name }
    let name: String
    let baseStat: Int

    var displayName: String {
        switch name {
        case "hp":               return "HP"
        case "attack":           return "Attack"
        case "defense":          return "Defense"
        case "special-attack":   return "Sp. Atk"
        case "special-defense":  return "Sp. Def"
        case "speed":            return "Speed"
        default:                 return name.capitalized
        }
    }

    var percentage: Double { min(Double(baseStat) / 255.0, 1.0) }
}

struct PokemonAbility: Identifiable, Sendable {
    var id: String { name }
    let name: String
    let isHidden: Bool

    var displayName: String {
        name.split(separator: "-").map { $0.capitalized }.joined(separator: " ")
    }
}

extension PokemonDetail {
    init(from detailDTO: PokemonDetailDTO, speciesDTO: PokemonSpeciesDTO?) {
        self.id = detailDTO.id
        self.name = detailDTO.name
        self.height = detailDTO.height
        self.weight = detailDTO.weight
        self.baseExperience = detailDTO.baseExperience ?? 0
        self.types = detailDTO.types.map { PokemonType(from: $0.type) }
        self.stats = detailDTO.stats.map { PokemonStat(name: $0.stat.name, baseStat: $0.baseStat) }
        self.abilities = detailDTO.abilities.map { PokemonAbility(name: $0.ability.name, isHidden: $0.isHidden) }
        self.moves = detailDTO.moves.map { PokemonMove(name: $0.move.name) }

        if let artworkURL = detailDTO.sprites.other?.officialArtwork?.frontDefault {
            self.spriteURL = URL(string: artworkURL)
        } else {
            self.spriteURL = detailDTO.sprites.frontDefault.flatMap { URL(string: $0) }
        }

        self.description = speciesDTO?.englishFlavorText
        self.genus = speciesDTO?.englishGenus
    }
}

También crea Features/Pokemon/Domain/Models/PokemonMove.swift:

import Foundation

struct PokemonMove: Identifiable, Sendable {
    var id: String { name }
    let name: String

    var displayName: String {
        name.split(separator: "-").map { $0.capitalized }.joined(separator: " ")
    }
}

Use Cases

Un Use Case encapsula una operación de negocio específica. Tiene exactamente un método: execute. No sabe de URLSession, no sabe de SwiftUI, no sabe de SwiftData. Solo sabe orquestar el Repository para devolver lo que la Presentation necesita.

Crea Features/Pokemon/Domain/UseCases/GetPokemonListUseCase.swift:

import Foundation

protocol GetPokemonListUseCaseProtocol: Sendable {
    func execute(limit: Int, offset: Int) async throws -> (pokemons: [Pokemon], hasMore: Bool, total: Int)
}

final class GetPokemonListUseCase: GetPokemonListUseCaseProtocol, Sendable {

    private let repository: PokemonRepositoryProtocol

    init(repository: PokemonRepositoryProtocol = PokemonRepository()) {
        self.repository = repository
    }

    func execute(limit: Int, offset: Int) async throws -> (pokemons: [Pokemon], hasMore: Bool, total: Int) {
        let dto = try await repository.fetchPokemonList(limit: limit, offset: offset)

        let pokemons = dto.results.map { Pokemon(from: $0) }
        let hasMore = dto.next != nil

        return (pokemons, hasMore, dto.count)
    }
}

Crea Features/Pokemon/Domain/UseCases/GetPokemonDetailUseCase.swift:

import Foundation

protocol GetPokemonDetailUseCaseProtocol: Sendable {
    func execute(idOrName: String) async throws -> PokemonDetail
}

final class GetPokemonDetailUseCase: GetPokemonDetailUseCaseProtocol, Sendable {

    private let repository: PokemonRepositoryProtocol

    init(repository: PokemonRepositoryProtocol = PokemonRepository()) {
        self.repository = repository
    }

    func execute(idOrName: String) async throws -> PokemonDetail {
        async let detailDTO  = repository.fetchPokemonDetail(idOrName: idOrName)
        async let speciesDTO = repository.fetchPokemonSpecies(idOrName: idOrName)

        let detail  = try await detailDTO
        let species = try? await speciesDTO

        return PokemonDetail(from: detail, speciesDTO: species)
    }
}

GetPokemonDetailUseCase hace dos llamadas a red en paralelo usando async let. Sin esto serían secuenciales: primero espera el detalle, luego pide la especie. Con async let ambas se inician al mismo tiempo y el try await espera el resultado cuando lo necesita.

speciesDTO usa try? porque la información de especie no es crítica — la app puede mostrar el detalle sin ella. Si la llamada falla, simplemente species es nil y PokemonDetail se crea sin descripción ni genus.


Qué viene en el siguiente post

En el Post 6 construimos la persistencia local con SwiftData: los tres modelos @Model y el servicio que hace CRUD sobre el ModelContext.