Building a Pokédex on iOS — Part 1: Project structure
First post in the PokeTracker series: what we're building, how we structure the project, and why we organize the code into layers.
This is a series of posts where we’ll build a complete iOS app from scratch: a Pokédex connected to a real API, with local persistence, image caching, favorites, filters, unit tests, and a CI/CD pipeline.
The goal isn’t to teach SwiftUI from the ground up. If you already know how to build views, bind state, and make network calls, this series is for you. What it tries to show is what a project looks like when it grows beyond a tutorial.
By the end of the series you’ll have a working app built piece by piece, with every decision explained along the way. We’ll publish the repository in the last post.
What we’re building
The app is called PokeTracker. It consumes the PokéAPI and includes these features:
- Paginated Pokémon list with infinite scroll
- Search by name or number
- Detail view with stats, moves, abilities, evolution chain, and type effectiveness
- Favorites with local persistence
- Filters by generation and type
- List and grid views
- Offline cache — if there’s no network, the app keeps showing what it already downloaded
- Two-level image cache: memory and disk
Stack: SwiftUI, SwiftData, async/await, Swift Testing.
How we structure the project
Before creating the project in Xcode, it’s worth talking about structure — it’s the decision that’s hardest to change later.
The most documented architecture in iOS tutorials is MVVM. It works well for small apps and the SwiftUI ecosystem naturally favors it. For a larger project there are options like TCA, VIPER, or the one we use here: something close to Clean Architecture.
This isn’t an argument that Clean Architecture is better. It’s simply the one with the fewest complete examples on the internet for iOS with modern SwiftUI. If your team works well with MVVM, stick with MVVM. The architecture your team doesn’t know isn’t the right one for production.
What we can say: separating the data, domain, and presentation layers makes the project easier to test and extend. You’ll see that in the testing posts.
Creating the project in Xcode
Open Xcode → File → New → Project → iOS → App.
Configure:
- Product Name: PokeTracker
- Team: your development team
- Bundle Identifier: yours (e.g.
dev.slekens.PokeTracker) - Interface: SwiftUI
- Language: Swift
- Storage: None (we’ll configure SwiftData manually)
- Include Tests: ✅ checked
Save wherever you prefer. Xcode creates the project with ContentView.swift and the test targets. Delete ContentView.swift and Item.swift if it appears — we won’t need them.
Folder structure
Create this structure inside the main target:
PokeTracker/
├── PokeTrackerApp.swift
├── Core/
│ ├── Network/
│ ├── Cache/
│ └── Extensions/
└── Features/
└── Pokemon/
├── Data/
│ ├── DTOs/
│ ├── Persistence/
│ └── Repository/
├── Domain/
│ ├── Models/
│ └── UseCases/
└── Presentation/
├── ViewModels/
└── Views/
├── Atoms/
├── Molecules/
└── Screens/
In Xcode you can create groups (yellow folders) with New Group from the context menu. The groups that create a folder on disk are the ones that matter.
Why Core and Features:
Core contains code that doesn’t belong to any specific feature: the HTTP client, the image cache system, SDK type extensions. If you add a second feature tomorrow, Core doesn’t change.
Features groups everything related to a piece of functionality in one place. Here we only have Pokemon, but in a real app you might have Auth, Profile, Settings, each with their own layers.
Why Data, Domain, and Presentation:
Each layer has one responsibility:
Datatalks to the outside world: the API, the local database.Domaindefines what a Pokémon is for this app, independently of how it comes from the API or how it gets stored.Presentationdisplays data and captures user actions.
The dependency rule flows in one direction: Presentation uses Domain, Data implements what Domain defines. No layer imports from the one “above” it. This is what makes it possible to test Domain without needing SwiftUI or SwiftData.
The entry point: PokeTrackerApp.swift
Create PokeTrackerApp.swift with this content:
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)
}
}
The ModelContainer is the heart of SwiftData. It defines which models the app will persist. We create it here, at the entry point, and inject it into the entire view hierarchy with .modelContainer(...). Any view that needs to read or write data asks for the ModelContext from the environment.
The three models — CachedPokemon, CachedPokemonDetail, FavoritePokemon — we’ll create in Post 6. For now the project won’t compile, and that’s fine. We’ll build layer by layer.
What’s next
In Post 2 we build the network layer: the HTTP client, the endpoints enum, and typed errors. It’s the first concrete piece of the project.
If you have questions about the structure or something wasn’t clear, you can write to me directly from the chat at slekens.dev.