← Back to blog

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: FFF, 888.


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.