Construyendo una Pokédex en iOS — Parte 1: Estructura del proyecto
Primer post de la serie PokeTracker: qué vamos a construir, cómo estructuramos el proyecto y por qué organizamos el código en capas.
Esta es una serie de posts donde vamos a construir una app iOS completa desde cero: una Pokédex conectada a una API real, con persistencia local, caché de imágenes, favoritos, filtros, pruebas unitarias y un pipeline de CI/CD.
El objetivo no es enseñar SwiftUI desde lo más básico. Si ya sabes crear vistas, enlazar estados y hacer llamadas a red, esto es para ti. Lo que intenta esta serie es mostrar cómo se ve un proyecto cuando crece más allá de un tutorial.
Al final de la serie tendrás una app funcional construida pieza por pieza, con cada decisión explicada. El repositorio lo publicamos en el último post.
Qué vamos a construir
La app se llama PokeTracker. Consume la PokéAPI y tiene estas funcionalidades:
- Lista paginada de Pokémon con scroll infinito
- Búsqueda por nombre o número
- Detalle con estadísticas, movimientos, habilidades, cadena de evolución y efectividad de tipos
- Favoritos con persistencia local
- Filtros por generación y tipo
- Vista de lista y cuadrícula
- Caché offline — si no hay red, la app sigue mostrando lo que ya descargó
- Caché de imágenes en dos niveles: memoria y disco
Stack: SwiftUI, SwiftData, async/await, Swift Testing.
Cómo estructuramos el proyecto
Antes de crear el proyecto en Xcode, vale la pena hablar de estructura porque es la decisión que más cuesta cambiar después.
La arquitectura más documentada en tutoriales de iOS es MVVM. Funciona bien para apps pequeñas y el ecosistema SwiftUI la favorece naturalmente. Para un proyecto más grande hay opciones como TCA, VIPER o la que usamos aquí: algo cercano a Clean Architecture.
Esto no es un argumento de que Clean Architecture sea mejor. Es simplemente la que menos ejemplos completos tiene en internet para iOS con SwiftUI moderno. Si tu equipo trabaja bien con MVVM, quédate con MVVM. La arquitectura que no conoces tu equipo no es la correcta para producción.
Lo que sí podemos decir: separar las capas de datos, dominio y presentación hace que el proyecto sea más fácil de probar y de extender. Eso lo verás en los posts de testing.
Crear el proyecto en Xcode
Abre Xcode → File → New → Project → iOS → App.
Configura:
- Product Name: PokeTracker
- Team: tu equipo de desarrollo
- Bundle Identifier: el tuyo (ej.
dev.slekens.PokeTracker) - Interface: SwiftUI
- Language: Swift
- Storage: None (vamos a configurar SwiftData a mano)
- Include Tests: ✅ activado
Guarda en la ubicación que prefieras. Xcode crea el proyecto con ContentView.swift y los targets de test. Borra ContentView.swift y Item.swift si aparece — no los vamos a necesitar.
Estructura de carpetas
Crea esta estructura dentro del target principal:
PokeTracker/
├── PokeTrackerApp.swift
├── Core/
│ ├── Network/
│ ├── Cache/
│ └── Extensions/
└── Features/
└── Pokemon/
├── Data/
│ ├── DTOs/
│ ├── Persistence/
│ └── Repository/
├── Domain/
│ ├── Models/
│ └── UseCases/
└── Presentation/
├── ViewModels/
└── Views/
├── Atoms/
├── Molecules/
└── Screens/
En Xcode puedes crear grupos (carpetas amarillas) con New Group desde el menú contextual. Los grupos que crean un folder en disco son los que importan.
Por qué Core y Features:
Core contiene código que no pertenece a ninguna feature específica: el cliente HTTP, el sistema de caché de imágenes, extensiones de tipos del SDK. Si mañana agregas una segunda feature, Core no cambia.
Features agrupa todo lo relacionado con una funcionalidad en un solo lugar. Aquí solo tenemos Pokemon, pero en una app real podrías tener Auth, Profile, Settings, cada una con sus propias capas.
Por qué Data, Domain y Presentation:
Cada capa tiene una responsabilidad:
Datahabla con el mundo exterior: la API, la base de datos local.Domaindefine qué es un Pokémon para esta app, independientemente de cómo venga de la API o cómo se guarde.Presentationmuestra datos y captura acciones del usuario.
La regla de dependencias va en una sola dirección: Presentation usa Domain, Data implementa lo que Domain define. Ninguna capa importa de la que está “arriba”. Esto es lo que hace posible probar Domain sin necesitar SwiftUI ni SwiftData.
El punto de entrada: PokeTrackerApp.swift
Crea PokeTrackerApp.swift con este contenido:
import SwiftUI
import SwiftData
@main
struct PokeTrackerApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
CachedPokemon.self,
CachedPokemonDetail.self,
FavoritePokemon.self,
])
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
PokemonListScreen()
}
.modelContainer(sharedModelContainer)
}
}
El ModelContainer es el corazón de SwiftData. Define qué modelos va a persistir la app. Lo creamos aquí, en el punto de entrada, y lo inyectamos en toda la jerarquía de vistas con .modelContainer(...). Cualquier vista que necesite leer o escribir datos pide el ModelContext del entorno.
Los tres modelos — CachedPokemon, CachedPokemonDetail, FavoritePokemon — los vamos a crear en el Post 6. Por ahora el proyecto no compila, y está bien. Vamos a ir construyendo capa por capa.
Qué viene en el siguiente post
En el Post 2 construimos la capa de red: el cliente HTTP, el enum de endpoints y los errores tipados. Es la primera pieza concreta del proyecto.
Si tienes dudas sobre la estructura o algo no quedó claro, puedes escribirme directamente desde el chat en slekens.dev.