iOS Architecture Guidelines
Scalable, testable, and maintainable iOS architecture patterns for building robust applications.
Overview
This guide covers iOS application architecture following Clean Architecture principles with iOS-specific implementations using Swift, SwiftUI, Combine, URLSession, and Core Data. For foundational Clean Architecture concepts, the dependency rule, and layer responsibilities, see Architecture Overview.
iOS Clean Architecture applies these principles through specific technologies: the domain layer uses pure Swift structs with async/await for business logic; the data layer implements repositories with URLSession for networking and Core Data for persistence; and the presentation layer uses SwiftUI with MVVM pattern and @Published properties for reactive UI state. This guide focuses on iOS-specific patterns including the Coordinator pattern for SwiftUI navigation, protocol-oriented dependency injection, and Combine integration across layers.
Core Principles
For general Clean Architecture principles (separation of concerns, dependency rule, single source of truth), see Architecture Overview. The following are iOS-specific architectural principles:
- Combine for Reactive Data: Use
@Publishedproperties in ViewModels withsink()or.onReceive()in SwiftUI views - this provides automatic UI updates when state changes - Coordinator Pattern for Navigation: Centralize navigation logic in Coordinators using
NavigationPathandnavigationDestination(for:)- this separates navigation from views making it testable and reusable - Protocol-Oriented DI: Use protocols for all dependencies with a DIContainer providing implementations - enables easy mocking for tests and runtime swapping
- Pure Swift Domain Layer: Domain models and use cases have zero imports of UIKit, SwiftUI, Core Data, or URLSession - use
Foundation,Decimal, and Swift concurrency only - Core Data as Cache: Core Data serves as the local cache with repository coordinating between network and cache for offline-first behavior
Clean Architecture Layers
iOS Clean Architecture applies the three-layer pattern (see Architecture Overview for conceptual details) using iOS-specific technologies:
iOS Layer Technologies
Presentation Layer: SwiftUI views with @StateObject for ViewModels, @EnvironmentObject for Coordinators, @Published properties for reactive state, NavigationStack with type-safe routing
Domain Layer: Use case protocols with async/await implementations, pure Swift structs for domain models (no UIKit/SwiftUI imports), repository protocols defining data contract
Data Layer: URLSession-based network manager with Codable DTOs, Core Data NSManagedObject entities with DAOs, mappers converting between DTO/Entity/Domain
MVVM with Combine
MVVM Architecture Diagram
MVVM Data Flow
ViewModel Pattern
// Presentation/ViewModels/PaymentListViewModel.swift
import Foundation
import Combine
@MainActor
final class PaymentListViewModel: ObservableObject {
// MARK: - Published State
@Published private(set) var state: PaymentListState = .loading
@Published private(set) var payments: [Payment] = []
@Published private(set) var isRefreshing = false
@Published var searchQuery = ""
// MARK: - Dependencies
private let getPaymentsUseCase: GetPaymentsUseCase
private let searchPaymentsUseCase: SearchPaymentsUseCase
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
getPaymentsUseCase: GetPaymentsUseCase,
searchPaymentsUseCase: SearchPaymentsUseCase
) {
self.getPaymentsUseCase = getPaymentsUseCase
self.searchPaymentsUseCase = searchPaymentsUseCase
setupSearchDebounce()
}
// MARK: - Public Methods
func loadPayments() async {
state = .loading
do {
payments = try await getPaymentsUseCase.execute()
state = .loaded
} catch {
state = .error(error.localizedDescription)
}
}
func refresh() async {
isRefreshing = true
defer { isRefreshing = false }
do {
payments = try await getPaymentsUseCase.execute()
state = .loaded
} catch {
// Silent failure on refresh - keep existing data
}
}
// MARK: - Private Methods
private func setupSearchDebounce() {
$searchQuery
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] query in
Task {
await self?.performSearch(query: query)
}
}
.store(in: &cancellables)
}
private func performSearch(query: String) async {
guard !query.isEmpty else {
// Show all payments if query is empty
await loadPayments()
return
}
do {
payments = try await searchPaymentsUseCase.execute(query: query)
state = .loaded
} catch {
state = .error(error.localizedDescription)
}
}
}
// MARK: - State
enum PaymentListState: Equatable {
case loading
case loaded
case error(String)
}
Advanced ViewModel with Multiple Publishers
// Presentation/ViewModels/CreatePaymentViewModel.swift
import Foundation
import Combine
@MainActor
final class CreatePaymentViewModel: ObservableObject {
// MARK: - Form State
@Published var selectedAccount: Account?
@Published var recipientName = ""
@Published var recipientAccountNumber = ""
@Published var amount = ""
@Published var description = ""
// MARK: - UI State
@Published private(set) var accounts: [Account] = []
@Published private(set) var isLoading = false
@Published private(set) var validationErrors: [ValidationError] = []
@Published private(set) var submitError: String?
// MARK: - Validation State
@Published private(set) var isFormValid = false
// MARK: - Dependencies
private let getAccountsUseCase: GetAccountsUseCase
private let createPaymentUseCase: CreatePaymentUseCase
private let validatePaymentUseCase: ValidatePaymentUseCase
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
getAccountsUseCase: GetAccountsUseCase,
createPaymentUseCase: CreatePaymentUseCase,
validatePaymentUseCase: ValidatePaymentUseCase
) {
self.getAccountsUseCase = getAccountsUseCase
self.createPaymentUseCase = createPaymentUseCase
self.validatePaymentUseCase = validatePaymentUseCase
setupFormValidation()
loadAccounts()
}
// MARK: - Public Methods
func submitPayment() async -> Bool {
guard isFormValid else {
return false
}
isLoading = true
submitError = nil
defer { isLoading = false }
do {
guard let account = selectedAccount,
let amountValue = Decimal(string: amount) else {
submitError = "Invalid input"
return false
}
let payment = try await createPaymentUseCase.execute(
accountId: account.id,
recipientName: recipientName,
recipientAccountNumber: recipientAccountNumber,
amount: amountValue,
currency: account.currency,
description: description.isEmpty ? nil : description
)
// Success
return true
} catch {
submitError = error.localizedDescription
return false
}
}
// MARK: - Private Methods
private func loadAccounts() {
Task {
do {
accounts = try await getAccountsUseCase.execute()
if accounts.count == 1 {
selectedAccount = accounts.first
}
} catch {
// Handle error
}
}
}
private func setupFormValidation() {
// Combine all form fields
Publishers.CombineLatest4(
$selectedAccount,
$recipientName,
$recipientAccountNumber,
$amount
)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] account, name, accountNumber, amount in
self?.validateForm(
account: account,
recipientName: name,
recipientAccountNumber: accountNumber,
amount: amount
)
}
.store(in: &cancellables)
}
private func validateForm(
account: Account?,
recipientName: String,
recipientAccountNumber: String,
amount: String
) {
var errors: [ValidationError] = []
// Account validation
if account == nil {
errors.append(.noAccountSelected)
}
// Recipient name validation
if recipientName.trimmingCharacters(in: .whitespaces).isEmpty {
errors.append(.emptyRecipientName)
}
// Account number validation
if !validatePaymentUseCase.isValidAccountNumber(recipientAccountNumber) {
errors.append(.invalidAccountNumber)
}
// Amount validation
if let amountValue = Decimal(string: amount) {
if amountValue <= 0 {
errors.append(.amountMustBePositive)
}
if let account = account, amountValue > account.balance {
errors.append(.insufficientFunds)
}
} else if !amount.isEmpty {
errors.append(.invalidAmountFormat)
}
validationErrors = errors
isFormValid = errors.isEmpty &&
account != nil &&
!recipientName.isEmpty &&
!recipientAccountNumber.isEmpty &&
!amount.isEmpty
}
}
// MARK: - Validation Error
enum ValidationError: Identifiable {
case noAccountSelected
case emptyRecipientName
case invalidAccountNumber
case invalidAmountFormat
case amountMustBePositive
case insufficientFunds
var id: String {
switch self {
case .noAccountSelected: return "noAccount"
case .emptyRecipientName: return "emptyName"
case .invalidAccountNumber: return "invalidAccount"
case .invalidAmountFormat: return "invalidAmount"
case .amountMustBePositive: return "positiveAmount"
case .insufficientFunds: return "insufficientFunds"
}
}
var message: String {
switch self {
case .noAccountSelected:
return "Please select an account"
case .emptyRecipientName:
return "Recipient name is required"
case .invalidAccountNumber:
return "Invalid account number format"
case .invalidAmountFormat:
return "Invalid amount format"
case .amountMustBePositive:
return "Amount must be greater than zero"
case .insufficientFunds:
return "Insufficient funds in selected account"
}
}
}
Coordinator Pattern
The Coordinator pattern separates navigation logic from view controllers/views, making navigation testable and reusable.
Coordinator Architecture
Coordinator Flow
Coordinator Protocol
// Presentation/Navigation/Coordinator.swift
import SwiftUI
protocol Coordinator: AnyObject {
associatedtype Route: Hashable
var navigationPath: NavigationPath { get set }
func navigate(to route: Route)
func pop()
func popToRoot()
}
extension Coordinator {
func navigate(to route: Route) {
navigationPath.append(route)
}
func pop() {
if !navigationPath.isEmpty {
navigationPath.removeLast()
}
}
func popToRoot() {
navigationPath.removeLast(navigationPath.count)
}
}
App Coordinator
// Presentation/Navigation/AppCoordinator.swift
import SwiftUI
import Combine
@MainActor
final class AppCoordinator: ObservableObject, Coordinator {
// MARK: - Published State
@Published var navigationPath = NavigationPath()
@Published var isAuthenticated = false
// MARK: - Dependencies
private let authService: AuthService
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(authService: AuthService = DIContainer.shared.authService) {
self.authService = authService
// Observe authentication state
authService.isAuthenticatedPublisher
.assign(to: &$isAuthenticated)
}
// MARK: - Navigation
func navigate(to route: AppRoute) {
navigationPath.append(route)
}
func showLogin() {
// Reset navigation and show login
popToRoot()
isAuthenticated = false
}
func logout() {
authService.logout()
showLogin()
}
@ViewBuilder
func view(for route: AppRoute) -> some View {
switch route {
case .paymentList:
PaymentListView()
case .paymentDetail(let payment):
PaymentDetailView(payment: payment)
case .createPayment:
CreatePaymentView()
case .profile:
ProfileView()
case .settings:
SettingsView()
case .accountList:
AccountListView()
case .accountDetail(let account):
AccountDetailView(account: account)
}
}
}
// MARK: - App Route
enum AppRoute: Hashable {
case paymentList
case paymentDetail(Payment)
case createPayment
case profile
case settings
case accountList
case accountDetail(Account)
}
Root View with Coordinator
// Presentation/Navigation/AppCoordinatorView.swift
import SwiftUI
struct AppCoordinatorView: View {
@StateObject private var coordinator = AppCoordinator()
var body: some View {
Group {
if coordinator.isAuthenticated {
NavigationStack(path: $coordinator.navigationPath) {
PaymentListView()
.navigationDestination(for: AppRoute.self) { route in
coordinator.view(for: route)
}
}
} else {
LoginView()
}
}
.environmentObject(coordinator)
}
}
// MARK: - Usage in Views
struct PaymentListView: View {
@EnvironmentObject private var coordinator: AppCoordinator
@StateObject private var viewModel = PaymentListViewModel()
var body: some View {
List(viewModel.payments) { payment in
Button {
coordinator.navigate(to: .paymentDetail(payment))
} label: {
PaymentCard(payment: payment)
}
}
.navigationTitle("Payments")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
coordinator.navigate(to: .createPayment)
} label: {
Image(systemName: "plus.circle.fill")
}
}
}
}
}
Domain Layer: Business Logic
Domain Layer Architecture
Use Case Flow
Use Cases
// Domain/UseCases/Payment/GetPaymentsUseCase.swift
import Foundation
protocol GetPaymentsUseCase {
func execute() async throws -> [Payment]
}
final class GetPaymentsUseCaseImpl: GetPaymentsUseCase {
// MARK: - Dependencies
private let repository: PaymentRepository
// MARK: - Initialization
init(repository: PaymentRepository) {
self.repository = repository
}
// MARK: - Execute
func execute() async throws -> [Payment] {
// 1. Try to get from repository (offline-first)
let payments = try await repository.getPayments()
// 2. Trigger background refresh
Task.detached(priority: .background) {
try? await self.repository.refreshPayments()
}
return payments
}
}
// Domain/UseCases/Payment/CreatePaymentUseCase.swift
import Foundation
protocol CreatePaymentUseCase {
func execute(
accountId: String,
recipientName: String,
recipientAccountNumber: String,
amount: Decimal,
currency: String,
description: String?
) async throws -> Payment
}
final class CreatePaymentUseCaseImpl: CreatePaymentUseCase {
// MARK: - Dependencies
private let paymentRepository: PaymentRepository
private let accountRepository: AccountRepository
private let validatePaymentUseCase: ValidatePaymentUseCase
// MARK: - Initialization
init(
paymentRepository: PaymentRepository,
accountRepository: AccountRepository,
validatePaymentUseCase: ValidatePaymentUseCase
) {
self.paymentRepository = paymentRepository
self.accountRepository = accountRepository
self.validatePaymentUseCase = validatePaymentUseCase
}
// MARK: - Execute
func execute(
accountId: String,
recipientName: String,
recipientAccountNumber: String,
amount: Decimal,
currency: String,
description: String?
) async throws -> Payment {
// 1. Get account
guard let account = try await accountRepository.getAccount(by: accountId) else {
throw PaymentError.accountNotFound
}
// 2. Validate account has sufficient funds
guard account.balance >= amount else {
throw PaymentError.insufficientFunds
}
// 3. Validate payment details
try validatePaymentUseCase.validate(
recipientAccountNumber: recipientAccountNumber,
amount: amount,
currency: currency
)
// 4. Create payment through repository
let payment = try await paymentRepository.createPayment(
accountId: accountId,
recipientName: recipientName,
recipientAccountNumber: recipientAccountNumber,
amount: amount,
currency: currency,
description: description
)
return payment
}
}
// MARK: - Payment Error
enum PaymentError: LocalizedError {
case accountNotFound
case insufficientFunds
case invalidAccountNumber
case invalidAmount
case invalidCurrency
var errorDescription: String? {
switch self {
case .accountNotFound:
return "Account not found"
case .insufficientFunds:
return "Insufficient funds"
case .invalidAccountNumber:
return "Invalid account number"
case .invalidAmount:
return "Invalid amount"
case .invalidCurrency:
return "Unsupported currency"
}
}
}
Repository Protocols
// Domain/Repositories/PaymentRepository.swift
import Foundation
protocol PaymentRepository {
/// Get all payments (offline-first, returns cached data)
func getPayments() async throws -> [Payment]
/// Get specific payment by ID
func getPayment(by id: String) async throws -> Payment?
/// Create new payment
func createPayment(
accountId: String,
recipientName: String,
recipientAccountNumber: String,
amount: Decimal,
currency: String,
description: String?
) async throws -> Payment
/// Cancel pending payment
func cancelPayment(id: String) async throws -> Payment
/// Refresh payments from remote
func refreshPayments() async throws
}
Data Layer: Implementation
Repository Implementation
// Data/Repositories/PaymentRepositoryImpl.swift
import Foundation
final class PaymentRepositoryImpl: PaymentRepository {
// MARK: - Dependencies
private let networkManager: NetworkManagerProtocol
private let paymentDAO: PaymentDAOProtocol
// MARK: - Initialization
init(
networkManager: NetworkManagerProtocol,
paymentDAO: PaymentDAOProtocol
) {
self.networkManager = networkManager
self.paymentDAO = paymentDAO
}
// MARK: - PaymentRepository
func getPayments() async throws -> [Payment] {
// Offline-first: Return local cache
return try await paymentDAO.getAll()
}
func getPayment(by id: String) async throws -> Payment? {
return try await paymentDAO.getById(id)
}
func createPayment(
accountId: String,
recipientName: String,
recipientAccountNumber: String,
amount: Decimal,
currency: String,
description: String?
) async throws -> Payment {
// 1. Create request DTO
let request = CreatePaymentRequest(
accountId: accountId,
recipientName: recipientName,
recipientAccountNumber: recipientAccountNumber,
amount: amount.description,
currency: currency,
description: description
)
// 2. Call API
let dto: PaymentDTO = try await networkManager.request(
PaymentEndpoint.createPayment(request)
)
// 3. Map to domain model
let payment = try dto.toDomain()
// 4. Cache locally
try await paymentDAO.save(payment)
return payment
}
func cancelPayment(id: String) async throws -> Payment {
let dto: PaymentDTO = try await networkManager.request(
PaymentEndpoint.cancelPayment(id: id)
)
let payment = try dto.toDomain()
// Update cache
try await paymentDAO.save(payment)
return payment
}
func refreshPayments() async throws {
// Fetch from API
let dtos: [PaymentDTO] = try await networkManager.request(
PaymentEndpoint.getPayments
)
// Map to domain models
let payments = try dtos.map { try $0.toDomain() }
// Update cache
try await paymentDAO.deleteAll()
try await paymentDAO.saveAll(payments)
}
}
Dependency Injection
Dependency Injection Architecture
Dependency Graph Example
DI Container
// DI/DIContainer.swift
import Foundation
final class DIContainer {
static let shared = DIContainer()
private init() {}
// MARK: - Core Services
lazy var networkManager: NetworkManagerProtocol = {
NetworkManager()
}()
lazy var coreDataStack: CoreDataStack = {
CoreDataStack.shared
}()
// MARK: - DAOs
lazy var paymentDAO: PaymentDAOProtocol = {
PaymentDAO(context: coreDataStack.viewContext)
}()
lazy var accountDAO: AccountDAOProtocol = {
AccountDAO(context: coreDataStack.viewContext)
}()
// MARK: - Repositories
lazy var paymentRepository: PaymentRepository = {
PaymentRepositoryImpl(
networkManager: networkManager,
paymentDAO: paymentDAO
)
}()
lazy var accountRepository: AccountRepository = {
AccountRepositoryImpl(
networkManager: networkManager,
accountDAO: accountDAO
)
}()
// MARK: - Use Cases
func makeGetPaymentsUseCase() -> GetPaymentsUseCase {
GetPaymentsUseCaseImpl(repository: paymentRepository)
}
func makeCreatePaymentUseCase() -> CreatePaymentUseCase {
CreatePaymentUseCaseImpl(
paymentRepository: paymentRepository,
accountRepository: accountRepository,
validatePaymentUseCase: makeValidatePaymentUseCase()
)
}
func makeValidatePaymentUseCase() -> ValidatePaymentUseCase {
ValidatePaymentUseCaseImpl()
}
func makeGetAccountsUseCase() -> GetAccountsUseCase {
GetAccountsUseCaseImpl(repository: accountRepository)
}
// MARK: - Services
lazy var authService: AuthService = {
AuthServiceImpl(
networkManager: networkManager,
keychainManager: KeychainManager.shared
)
}()
lazy var biometricAuthManager: BiometricAuthManager = {
BiometricAuthManager.shared
}()
}
// MARK: - ViewModel Factory
extension DIContainer {
func makePaymentListViewModel() -> PaymentListViewModel {
PaymentListViewModel(
getPaymentsUseCase: makeGetPaymentsUseCase(),
searchPaymentsUseCase: makeSearchPaymentsUseCase()
)
}
func makeCreatePaymentViewModel() -> CreatePaymentViewModel {
CreatePaymentViewModel(
getAccountsUseCase: makeGetAccountsUseCase(),
createPaymentUseCase: makeCreatePaymentUseCase(),
validatePaymentUseCase: makeValidatePaymentUseCase()
)
}
}
Environment Object Pattern
// Presentation/Views/PaymentListView.swift
import SwiftUI
struct PaymentListView: View {
@StateObject private var viewModel: PaymentListViewModel
init(viewModel: PaymentListViewModel? = nil) {
_viewModel = StateObject(
wrappedValue: viewModel ?? DIContainer.shared.makePaymentListViewModel()
)
}
var body: some View {
List(viewModel.payments) { payment in
PaymentCard(payment: payment)
}
.task {
await viewModel.loadPayments()
}
}
}
Unidirectional Data Flow
Unidirectional data flow enforces that data moves in one direction through the application layers - user actions flow upward (View → ViewModel → Use Case → Repository), while data flows downward (Repository → Use Case → ViewModel → View). This eliminates bidirectional dependencies where the Repository could call back into the ViewModel directly, which would create tight coupling and make the system harder to test. With unidirectional flow, every layer communicates through well-defined interfaces: Views call ViewModels via methods, ViewModels call UseCases via protocols, UseCases call Repositories via protocols. Data returns through the same path via completion handlers, async/await results, or published properties.
Data Flow Direction
Common Pitfalls
Don't: Mix Layers
// BAD: View calling repository directly
struct PaymentListView: View {
let repository: PaymentRepository
var body: some View {
Button("Load") {
Task {
let payments = try await repository.getPayments() // WRONG LAYER!
}
}
}
}
// GOOD: View uses ViewModel, ViewModel uses Use Case
@MainActor
final class PaymentListViewModel: ObservableObject {
private let getPaymentsUseCase: GetPaymentsUseCase
func loadPayments() async {
let payments = try await getPaymentsUseCase.execute()
}
}
Don't: Put Business Logic in ViewModel
// BAD: Business logic in ViewModel
@MainActor
final class CreatePaymentViewModel: ObservableObject {
func createPayment() async {
// Business validation in ViewModel (WRONG!)
guard amount > 0 else { return }
guard account.balance >= amount else { return }
// Direct repository call (WRONG!)
try await repository.createPayment(...)
}
}
// GOOD: Business logic in Use Case
final class CreatePaymentUseCaseImpl: CreatePaymentUseCase {
func execute(...) async throws -> Payment {
// Business validation in Use Case (CORRECT!)
guard amount > 0 else { throw PaymentError.invalidAmount }
guard account.balance >= amount else { throw PaymentError.insufficientFunds }
return try await repository.createPayment(...)
}
}
Don't: Tight Coupling
// BAD: Tight coupling to concrete implementation
final class PaymentListViewModel: ObservableObject {
private let repository = PaymentRepositoryImpl() // CONCRETE CLASS!
}
// GOOD: Dependency injection with protocol
final class PaymentListViewModel: ObservableObject {
private let getPaymentsUseCase: GetPaymentsUseCase // PROTOCOL!
init(getPaymentsUseCase: GetPaymentsUseCase) {
self.getPaymentsUseCase = getPaymentsUseCase
}
}
Further Reading
iOS Framework Guidelines
- iOS Overview - iOS project setup and dependency injection
- iOS UI Development - SwiftUI and MVVM presentation layer
- iOS Data & Networking - Repository pattern implementation
- iOS Security - Secure architecture patterns
- iOS Testing - Testing layered architecture
- iOS Performance - Performance optimization per layer
Architecture Guidelines
- Architecture Overview - Clean Architecture concepts and dependency rule
- Microservices Architecture - Backend architecture patterns
- Mobile Overview - Mobile architecture guidance
- Mobile Navigation - Navigation architecture
Language and Patterns
- Swift Best Practices - Swift idioms and patterns
- Swift Concurrency - Async/await in architecture
- API Overview - API integration patterns
- Data Overview - Data layer architecture
External Resources
- Clean Architecture - Uncle Bob's original article
- iOS Architecture Patterns - iOS-specific patterns
- Coordinator Pattern - Navigation patterns
- Combine Framework - Reactive programming
- SwiftUI Architecture - SwiftUI patterns
Summary
Key Takeaways
- Clean Architecture layers - Domain (business logic), Data (sources), Presentation (UI)
- Dependency rule - Dependencies point inward; domain has no framework dependencies
- MVVM with Combine - Reactive ViewModels with @Published properties
- Coordinator pattern - Centralized, testable navigation logic
- Use cases - Encapsulate business logic and orchestrate data flow
- Repository pattern - Single source of truth with offline-first strategy
- Protocol-oriented - Use protocols for abstraction and testability
- Dependency injection - DI container for managing dependencies
- Unidirectional data flow - Events up, state down
- Testability - Each layer testable in isolation
Next Steps: Review iOS Testing to learn how to test each architectural layer independently, iOS Data & Networking for repository implementation details, and iOS UI Development for SwiftUI and navigation patterns.