Naming conventions, architectural rules, and Combine patterns for consistency.
- ViewController:
PokemonListViewController.swift - ViewModel:
PokemonListViewModel.swift - View/Cell:
PokemonCell.swift,DetailHeaderView.swift - UseCase:
FetchPokemonListUseCase.swift - Repository:
PokemonRepository.swift - Entity:
Pokemon.swift - DTO:
PokemonDetailDTO.swift - Protocol:
PokemonRepositoryProtocol.swift - Enum:
APIEndpoint.swift,APIError.swift,PokemonType.swift
class PokemonListViewController { }
struct Pokemon { }
enum PokemonType { case fire, water }
protocol PokemonRepositoryProtocol { }var pokemonList: [Pokemon] = []
let imageURL: URL?
var isLoading = false
let currentOffset = 0func loadPokemon() { }
func fetchPokemonDetail(id: Int) -> AnyPublisher { }
func isFavorite(id: Int) -> Bool { }private let pageSize = 20
private let debounceMs: UInt64 = 300
private let key = "pokemon_favorites"fileprivate var cancellables = Set<AnyCancellable>()
private let repository: PokemonRepositoryProtocol
private func mapToDetail(detail: PokemonDetailDTO) { }- Must not import: UIKit, Foundation.URLSession
- Can import: Foundation (for basic types, Codable, URL)
- Entities: Structs, immutable, no logic
- UseCases: Final classes, single responsibility, return AnyPublisher
- Protocols: Define contracts only, no default impl
- All DTOs live here, never used outside this layer
- Repositories: Final classes, implement Domain protocols
- APIClient: Single responsibility — HTTP + JSON decoding only
- APIEndpoint: Pure enum, no side effects
- Mapping: DTO → Entity happens in Repository methods
- Network errors: Map to typed APIError enum
Example (CORRECT):
// Data/Repositories/PokemonRepository.swift
func fetchPokemonList() -> AnyPublisher<[Pokemon], Error> {
apiClient.request(endpoint: .pokemonList)
.map { (dto: PokemonListResponseDTO) in
// DTO lives here, hidden from Domain
dto.results.map { Pokemon(...) }
}
.eraseToAnyPublisher()
}Example (WRONG):
// Domain/UseCases/FetchPokemonListUseCase.swift
// NEVER import or reference DTO
import PokeAPI // Don't expose Data layer
func execute() -> AnyPublisher<[PokemonDTO], Error> { } // WRONG- ViewController: Init with dependencies, setup in viewDidLoad()
- ViewModel: No UIKit imports, pure Swift + Combine
- Views: Init with default parameters, layout in layoutSubviews() or init(frame:)
- Cells: Reuse ID constant, register in ViewController
- BaseViewController: Provides cancellables Set, nothing else
// CORRECT
fetchUseCase.execute()
.sink { [weak self] value in
self?.vm.state = value
}
.store(in: &cancellables)
// WRONG
fetchUseCase.execute()
.sink { value in // Captures self strongly
self.vm.state = value
}
.store(in: &cancellables)// CORRECT
fetchUseCase.execute()
.receive(on: DispatchQueue.main) // Before sink
.sink { [weak self] in
self?.updateUI()
}
.store(in: &cancellables)
// WRONG
fetchUseCase.execute()
.sink { [weak self] in
DispatchQueue.main.async { // Too late, causes race
self?.updateUI()
}
}
.store(in: &cancellables)// CORRECT
apiClient.request(...)
.sink { completion in }
receiveValue: { value in }
.store(in: &cancellables)
// WRONG
apiClient.request(...)
.sink { completion in } // Subscription deallocated immediately
receiveValue: { value in }
// Missing .store()// CORRECT: Handle completion + value separately
.sink(
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.error = error.localizedDescription
}
},
receiveValue: { [weak self] data in
self?.data = data
}
)
// OK: Ignore completion
.sink { [weak self] data in
self?.data = data
}
// WRONG: Ignore errors
.sink(receiveCompletion: { _ in }) // Silent failure// CORRECT: Hide internal type from caller
func fetchPokemon() -> AnyPublisher<[Pokemon], Error> {
apiClient.request(endpoint: .list)
.map { /* transform */ }
.eraseToAnyPublisher()
}
// WRONG: Expose internal chain type
func fetchPokemon() -> Publishers.Map<
URLSession.DataTaskPublisher,
[Pokemon]
> {
// Too specific, changes break callers
}// CORRECT: Reactive filtering
Publishers.CombineLatest3($searchText, $selectedType, $pokemon)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.map { search, type, all in
all.filter { /* conditions */ }
}
.assign(to: &$filteredPokemon)
// WRONG: Manual observer pattern
$searchText.sink { [weak self] text in
self?.updateFilters() // Called 3 times separately
}
$selectedType.sink { [weak self] type in
self?.updateFilters()
}
// Inefficient, unpredictable order// CORRECT: Wait for both
detailPublisher
.zip(speciesPublisher)
.map { detail, species in
PokemonDetail(from: detail, species: species)
}
// WRONG: Sequential nested
detailPublisher
.flatMap { detail in
speciesPublisher.map { species in
(detail, species)
}
}
// Slower, harder to readimport Foundation
import Combine
import UIKit // Only in Presentation
// MARK: - Class/Struct Definition
class ViewModel {
// MARK: - Published State
@Published var state: String
// MARK: - Properties
private let repository: RepositoryProtocol
// MARK: - Init
init(repository: RepositoryProtocol) { }
// MARK: - Lifecycle (VCs only)
override func viewDidLoad() { }
// MARK: - Public Methods
func loadData() { }
// MARK: - Private Methods
private func setupBindings() { }
// MARK: - Combine Pipeline
private func setupFilterPipeline() { }
}// GOOD: Explains WHY
/// Load paginated results with debounce to avoid excessive requests
func loadMore() { }
// BAD: States obvious
// Set isLoading to true
isLoading = true| Keyword | Usage |
|---|---|
public |
Never in app (single-target, not library) |
internal |
Default, omit keyword |
fileprivate |
Rarely, prefer private with extension |
private |
Default for properties/methods |
Example:
final class PokemonListViewModel {
@Published var pokemon: [Pokemon] = [] // internal
private var cancellables = Set<AnyCancellable>()
func loadPokemon() { } // internal
private func setupFilterPipeline() { }
}- Explicit for public/fileprivate properties
- Infer for local variables when obvious
// GOOD
@Published var pokemon: [Pokemon] = []
let imageURL: URL? = nil
var filtered = pokemon.filter { $0.name.contains(query) } // Inferred OK
// BAD
let x = 5 // What is x?
var pokemon = [] // Type unknown- Use typed error enums (APIError) in Data layer
- Use Error protocol in Domain (generic)
- Convert to user strings in ViewModel
enum APIError: LocalizedError {
case invalidURL
case networkError(Error)
case decodingError(Error)
case notFound
case serverError(Int)
var errorDescription: String? {
// User-friendly messages
}
}Mark classes as final unless designed for subclassing.
final class PokemonRepository { } // CORRECT
class PokemonRepository { } // Allows accidental inheritance// GOOD
if let url = imageURL { /* use */ }
let url = imageURL ?? URL(string: "default")!
// BAD
let url = imageURL! // Crash if nilExceptions: Force unwrap only in initializers with constants.
let baseURL = URL(string: "https://pokeapi.co/api/v2")! // OK, compile-time constant- File name matches main type (PokemonListViewController.swift)
- No public types (internal default)
- DTOs stay in Data layer
- UIKit not in Domain/Data
- [weak self] in all Combine closures
- .receive(on: .main) before UI updates
- .store(in: &cancellables) on all subscriptions
- .eraseToAnyPublisher() on public publishers
- Typed errors (APIError enum)
- final class by default