Clean Architecture with MVVM + Combine reactive pipeline.
┌─────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER (UIKit) │
│ ViewController → ViewModel (@Published) → UIView │
│ PokemonListViewController, PokemonDetailViewController │
└──────────────┬──────────────────────────────────────────┘
│ (calls UseCases)
┌──────────────▼──────────────────────────────────────────┐
│ DOMAIN LAYER (Pure Swift) │
│ UseCases ← Entities + Repository Protocols │
│ FetchPokemonListUseCase, PokemonRepositoryProtocol │
└──────────────┬──────────────────────────────────────────┘
│ (depends on)
┌──────────────▼──────────────────────────────────────────┐
│ DATA LAYER (Implementation) │
│ Repositories (DTO → Entity mapping) │
│ Network: APIClient + APIEndpoint │
│ Local: FavoritesRepository (UserDefaults) │
└──────────────┬──────────────────────────────────────────┘
│ (makes requests to)
┌──────────────▼──────────────────────────────────────────┐
│ EXTERNAL (PokeAPI v2 REST) │
│ https://pokeapi.co/api/v2/pokemon, /pokemon/{id}, etc. │
└─────────────────────────────────────────────────────────┘
User taps PokemonListViewController
↓
viewDidLoad()
↓
viewModel.loadPokemon()
↓
FetchPokemonListUseCase.execute(offset, limit)
↓
PokemonRepository.fetchPokemonList()
↓
APIClient.request<PokemonListResponseDTO>(.pokemonList)
↓
URLSession.dataTaskPublisher() → tryMap() → decode() → mapError()
↓
PokemonListResponseDTO received
↓
Map: [PokemonListResponseDTO.result] → [Pokemon] (add imageURL)
↓
AnyPublisher<[Pokemon], Error> flows back through chain
↓
ViewModel.sink() → receive(on: .main)
↓
@Published var pokemon: [Pokemon] updated
↓
ViewController observes @Published (sink listener)
↓
collectionView.reloadData() ← PokemonCell displays
PokeAPI JSON (/pokemon?offset=0&limit=20)
↓
URLSession.dataTaskPublisher() decodes JSON
↓
PokemonListResponseDTO (DTO layer)
│
├─ results[].id
├─ results[].name
└─ results[].url (ignored, id extracted)
↓
Repository maps DTO → Domain Entity
│
├─ id: Int
├─ name: String (capitalized)
├─ imageURL: URL? (constructed from id)
└─ types: [PokemonType] (empty for list, filled in detail)
↓
[Pokemon] entity array
↓
ViewModel @Published property
↓
UICollectionView DataSource reads pokemon[]
↓
PokemonCell renders: image + name + ID + type badges
session.dataTaskPublisher(for: url)
.tryMap { data, response in /* validate */ }
.decode(type: T.self, decoder: decoder)
.mapError { /* convert to APIError */ }
.eraseToAnyPublisher()Key: Error handling at each stage, strong typing via decode().
fetchListUseCase.execute(offset, limit)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
},
receiveValue: { [weak self] newPokemon in
self?.pokemon.append(contentsOf: newPokemon)
}
)
.store(in: &cancellables)Key: Always [weak self], always .receive(on: .main), always .store().
Publishers.CombineLatest3($searchText, $selectedType, $pokemon)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.map { searchText, type, allPokemon in
/* apply filters */
}
.assign(to: &$filteredPokemon)Key: Combine 3 inputs, debounce user typing, auto-assign to @Published property.
detailPublisher // AnyPublisher<PokemonDetailDTO, APIError>
.zip(speciesPublisher) // AnyPublisher<PokemonSpeciesDTO, APIError>
.map { detail, species in
/* merge into single entity */
}Key: Wait for both requests, map combined result.
DIContainer (Singleton Pattern)
DIContainer.shared
├── lazy var apiClient: APIClient
├── lazy var pokemonRepository: PokemonRepositoryProtocol
├── lazy var favoritesRepository: FavoritesRepositoryProtocol
└── factory methods:
├── makeFetchPokemonListUseCase()
├── makeFetchPokemonDetailUseCase()
├── makeFetchEvolutionChainUseCase()
└── makeSearchPokemonUseCase()
ViewController Injection
let vm = PokemonListViewModel(
fetchListUseCase: container.makeFetchPokemonListUseCase(),
favoritesRepository: container.favoritesRepository
)
let vc = PokemonListViewController(viewModel: vm, container: container)- Singletons: APIClient, Repositories held by DIContainer (lifetime = app)
- ViewControllers: ViewModel captured weakly in closures ([weak self])
- Subscriptions: .store(in: &cancellables) on BaseViewController property
- Cancellation: Auto-deinit when ViewController pops (cancellables released)
| Layer | Responsibility | Never Includes |
|---|---|---|
| Domain | Business logic, entity definitions, use case orchestration | UIKit, Network, Persistence |
| Data | API calls, DTO decoding, Repository impl, local storage | UI logic, business rules |
| Presentation | ViewController lifecycle, state binding, user interaction | Network calls, database queries |
- Combine over RxSwift: Native, smaller surface area, good for MVVM
- Constructor DI over Service Locator: Explicit dependencies, testable
- Weak self pattern: Prevents cycles (VC → VM → Use Case → VM cycle)
- Debounce search: .milliseconds(300) for responsive filtering
- Pagination: offset/limit loop, 20 per page, canLoadMore flag
- Favorites persistence: UserDefaults (simple, no need for CoreData)
- Error handling: Typed APIError enum, user-facing strings
- Domain layer: Unit tests for UseCases (mock repositories)
- Data layer: Unit tests for Repositories (mock APIClient)
- Presentation layer: ViewModel unit tests (mock use cases), ViewController UI tests (mock VMs)
- Integration: Network mock tests (validate DTO → Entity mapping)