Building a Pokédex on iOS — Part 5: Domain models and Use Cases
Part 5 of the PokeTracker series: we create the app's own models and the Use Cases that convert DTOs into data ready for the interface.
So far we have the network layer and the DTOs. DTOs reflect what the API returns — they speak the PokéAPI’s language, not our app’s language. Domain models are the app’s own types: Pokemon, PokemonDetail, PokemonType. They don’t depend on any API, they have no optional properties just to “accommodate” JSON, and they carry the logic that makes sense for the interface.
Pokemon
Create 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 is the simplest model in the app. The initialization from the DTO lives in an extension to separate the mapping logic from the struct itself.
formattedID converts 1 into "#001" directly in the model. Not in the view, not in the ViewModel — here, where it makes sense to keep it as model behavior.
PokemonType
Create Features/Pokemon/Domain/Models/PokemonType.swift. Pokémon types have an associated color and icon — that logic lives in the domain, not in the view:
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 doesn’t have a native initializer for hex strings, so we add one ourselves. The process has three steps: clean the string (strip any # or whitespace), read the bytes with Scanner, and build the Color with RGB components normalized between 0 and 1.
The switch on hex.count handles the three formats you’ll encounter in practice: "F00" (3 digits, no alpha), "FF0000" (6 digits, no alpha), and "FF0000FF" (8 digits, with alpha). For the 3-digit format, each channel is expanded by multiplying by 17 — equivalent to doubling the digit: F → FF, 8 → 88.
PokemonDetail
Create Features/Pokemon/Domain/Models/PokemonDetail.swift. This model aggregates information from two different endpoints — detail and species:
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
}
}
Also create 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
A Use Case encapsulates a specific business operation. It has exactly one method: execute. It knows nothing about URLSession, nothing about SwiftUI, nothing about SwiftData. It only knows how to orchestrate the Repository to return what the Presentation layer needs.
Create 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)
}
}
Create 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 makes two network calls in parallel using async let. Without this they would be sequential: first wait for the detail, then request the species. With async let both are kicked off at the same time and try await waits for the result when it’s needed.
speciesDTO uses try? because the species information is not critical — the app can display the detail without it. If the call fails, species is simply nil and PokemonDetail is created without a description or genus.
What’s coming in the next post
In Post 6 we build local persistence with SwiftData: the three @Model models and the service that does CRUD on the ModelContext.