Skip to main content

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:

  1. Combine for Reactive Data: Use @Published properties in ViewModels with sink() or .onReceive() in SwiftUI views - this provides automatic UI updates when state changes
  2. Coordinator Pattern for Navigation: Centralize navigation logic in Coordinators using NavigationPath and navigationDestination(for:) - this separates navigation from views making it testable and reusable
  3. Protocol-Oriented DI: Use protocols for all dependencies with a DIContainer providing implementations - enables easy mocking for tests and runtime swapping
  4. 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
  5. 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

Architecture Guidelines

Language and Patterns

External Resources


Summary

Key Takeaways

  1. Clean Architecture layers - Domain (business logic), Data (sources), Presentation (UI)
  2. Dependency rule - Dependencies point inward; domain has no framework dependencies
  3. MVVM with Combine - Reactive ViewModels with @Published properties
  4. Coordinator pattern - Centralized, testable navigation logic
  5. Use cases - Encapsulate business logic and orchestrate data flow
  6. Repository pattern - Single source of truth with offline-first strategy
  7. Protocol-oriented - Use protocols for abstraction and testability
  8. Dependency injection - DI container for managing dependencies
  9. Unidirectional data flow - Events up, state down
  10. 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.