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: F → FF, 8 → 88.
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.