Skip to content

Swift App Architecture — MVVM, Clean Architecture, and Design Patterns

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Swift App Architecture. We cover key concepts, practical examples, and best practices to help you master this topic.

Swift app architecture patterns like MVVM, Clean Architecture, and the Coordinator pattern provide a structured approach to building scalable, testable, and maintainable iOS applications that can grow from a single screen to hundreds.

What You'll Learn

  • MVVM (Model-View-ViewModel) pattern in depth
  • Clean Architecture layers (Presentation, Domain, Data)
  • Coordinator pattern for navigation
  • Dependency injection (manual and with libraries)
  • Repository pattern for data access
  • Testing architectures

Why It Matters

Without a clear architecture, iOS apps become "massive view controllers" — unreadable, untestable, and impossible to refactor. A well-chosen architecture enforces Separation Of Concerns, makes code testable, and lets multiple developers work on the same app without conflicts.

Real-World Use

A team of 12 developers builds a banking app. They use Clean Architecture with MVVM: the Presentation layer has SwiftUI views and ViewModels, the Domain layer has use cases and business logic, and the Data layer has repositories that talk to API and database. Each layer is independently testable, and new features follow the same structure.

Learning Path

flowchart LR
  A[Server-Side Swift
Lesson 38] --> B[App Architecture
You are here] B --> C[Swift Ecosystem
Lesson 40] style B fill:#f90,color:#fff

MVVM Pattern

MVVM separates the view (SwiftUI/UIKit), the view model (state and logic), and the model (data).

import SwiftUI
import Combine

// MARK: - Model
struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
}

// MARK: - View Model
@MainActor
class UserListViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let repository: UserRepositoryProtocol

    init(repository: UserRepositoryProtocol) {
        self.repository = repository
    }

    func loadUsers() async {
        isLoading = true
        errorMessage = nil

        do {
            users = try await repository.fetchAll()
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    func deleteUser(_ id: Int) async {
        do {
            try await repository.delete(id: id)
            users.removeAll { $0.id == id }
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// MARK: - View
struct UserListView: View {
    @StateObject private var viewModel: UserListViewModel

    init(viewModel: UserListViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
    }

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView()
                } else if let error = viewModel.errorMessage {
                    VStack {
                        Text(error).foregroundColor(.red)
                        Button("Retry") {
                            Task { await viewModel.loadUsers() }
                        }
                    }
                } else {
                    List(viewModel.users) { user in
                        Text(user.name)
                    }
                }
            }
            .navigationTitle("Users")
            .task { await viewModel.loadUsers() }
        }
    }
}

Clean Architecture Layers

Clean Architecture separates code into three layers with strict dependency rules.

Domain Layer (innermost)

import Foundation

// MARK: - Entities
struct Task: Identifiable, Equatable {
    let id: UUID
    var title: String
    var isCompleted: Bool
    let createdAt: Date
}

// MARK: - Repository Interface
protocol TaskRepositoryProtocol {
    func fetchAll() async throws -> [Task]
    func save(_ tasks: [Task]) async throws
    func delete(_ id: UUID) async throws
}

// MARK: - Use Cases
class GetTasksUseCase {
    private let repository: TaskRepositoryProtocol

    init(repository: TaskRepositoryProtocol) {
        self.repository = repository
    }

    func execute() async throws -> [Task] {
        let tasks = try await repository.fetchAll()
        return tasks.sorted { $0.createdAt > $1.createdAt }
    }
}

class ToggleTaskUseCase {
    private let repository: TaskRepositoryProtocol

    init(repository: TaskRepositoryProtocol) {
        self.repository = repository
    }

    func execute(taskID: UUID) async throws -> Task? {
        var tasks = try await repository.fetchAll()
        guard let index = tasks.firstIndex(where: { $0.id == taskID }) else {
            return nil
        }
        tasks[index].isCompleted.toggle()
        try await repository.save(tasks)
        return tasks[index]
    }
}

Data Layer

import Foundation

// MARK: - Repository Implementation
class TaskRepository: TaskRepositoryProtocol {
    private let localDataSource: LocalTaskDataSource
    private let remoteDataSource: RemoteTaskDataSource

    init(localDataSource: LocalTaskDataSource,
         remoteDataSource: RemoteTaskDataSource) {
        self.localDataSource = localDataSource
        self.remoteDataSource = remoteDataSource
    }

    func fetchAll() async throws -> [Task] {
        // Try remote first, fall back to local
        do {
            let tasks = try await remoteDataSource.fetchAll()
            try await localDataSource.save(tasks)
            return tasks
        } catch {
            return try await localDataSource.fetchAll()
        }
    }

    func save(_ tasks: [Task]) async throws {
        try await localDataSource.save(tasks)
        try await remoteDataSource.sync(tasks)
    }

    func delete(_ id: UUID) async throws {
        try await localDataSource.delete(id)
        try await remoteDataSource.delete(id)
    }
}

// MARK: - Data Sources
protocol LocalTaskDataSource {
    func fetchAll() async throws -> [Task]
    func save(_ tasks: [Task]) async throws
    func delete(_ id: UUID) async throws
}

protocol RemoteTaskDataSource {
    func fetchAll() async throws -> [Task]
    func sync(_ tasks: [Task]) async throws
    func delete(_ id: UUID) async throws
}

class UserDefaultsTaskDataSource: LocalTaskDataSource {
    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()
    private let key = "tasks"

    func fetchAll() async throws -> [Task] {
        guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
        return try decoder.decode([Task].self, from: data)
    }

    func save(_ tasks: [Task]) async throws {
        let data = try encoder.encode(tasks)
        UserDefaults.standard.set(data, forKey: key)
    }

    func delete(_ id: UUID) async throws {
        var tasks = try await fetchAll()
        tasks.removeAll { $0.id == id }
        try await save(tasks)
    }
}

Presentation Layer

import SwiftUI

// MARK: - View Model uses Use Cases
@MainActor
class TaskListViewModel: ObservableObject {
    @Published var tasks: [Task] = []
    @Published var isLoading = false

    private let getTasksUseCase: GetTasksUseCase
    private let toggleTaskUseCase: ToggleTaskUseCase

    init(getTasksUseCase: GetTasksUseCase,
         toggleTaskUseCase: ToggleTaskUseCase) {
        self.getTasksUseCase = getTasksUseCase
        self.toggleTaskUseCase = toggleTaskUseCase
    }

    func loadTasks() async {
        isLoading = true
        do {
            tasks = try await getTasksUseCase.execute()
        } catch {
            print("Error: \(error)")
        }
        isLoading = false
    }

    func toggleTask(_ id: UUID) async {
        if let updated = try? await toggleTaskUseCase.execute(taskID: id),
           let index = tasks.firstIndex(where: { $0.id == id }) {
            tasks[index] = updated
        }
    }
}

Dependency Injection

Manual dependency injection provides clean, explicit dependencies.

import SwiftUI

// MARK: - Composition Root
struct AppDependencies {
    let localDataSource: LocalTaskDataSource
    let remoteDataSource: RemoteTaskDataSource
    let taskRepository: TaskRepositoryProtocol
    let getTasksUseCase: GetTasksUseCase
    let toggleTaskUseCase: ToggleTaskUseCase

    init() {
        localDataSource = UserDefaultsTaskDataSource()
        remoteDataSource = APITaskDataSource()
        taskRepository = TaskRepository(
            localDataSource: localDataSource,
            remoteDataSource: remoteDataSource
        )
        getTasksUseCase = GetTasksUseCase(repository: taskRepository)
        toggleTaskUseCase = ToggleTaskUseCase(repository: taskRepository)
    }
}

// MARK: - App Entry Point
@main
struct TaskApp: App {
    let dependencies = AppDependencies()

    var body: some Scene {
        WindowGroup {
            TaskListView(
                viewModel: TaskListViewModel(
                    getTasksUseCase: dependencies.getTasksUseCase,
                    toggleTaskUseCase: dependencies.toggleTaskUseCase
                )
            )
        }
    }
}

Coordinator Pattern

Coordinators manage navigation flow, removing navigation logic from views.

import SwiftUI

// MARK: - Coordinator Protocol
protocol Coordinator: AnyObject {
    var navigationController: UINavigationController { get }
    func start()
    func showDetail(for item: Item)
    func showSettings()
    func logout()
}

// MARK: - App Coordinator
class AppCoordinator: Coordinator {
    let navigationController: UINavigationController
    private let dependencies: AppDependencies

    init(navigationController: UINavigationController,
         dependencies: AppDependencies) {
        self.navigationController = navigationController
        self.dependencies = dependencies
    }

    func start() {
        let viewModel = ItemListViewModel(coordinator: self, repository: dependencies.itemRepository)
        let view = ItemListView(viewModel: viewModel)
        let hosting = UIHostingController(rootView: view)
        navigationController.setViewControllers([hosting], animated: false)
    }

    func showDetail(for item: Item) {
        let viewModel = ItemDetailViewModel(coordinator: self, item: item)
        let view = ItemDetailView(viewModel: viewModel)
        let hosting = UIHostingController(rootView: view)
        navigationController.pushViewController(hosting, animated: true)
    }

    func showSettings() {
        let viewModel = SettingsViewModel(coordinator: self)
        let view = SettingsView(viewModel: viewModel)
        let hosting = UIHostingController(rootView: view)
        navigationController.pushViewController(hosting, animated: true)
    }

    func logout() {
        navigationController.popToRootViewController(animated: true)
        // Clear auth state
    }
}

// MARK: - Coordinator in ViewModel
class ItemListViewModel: ObservableObject {
    weak var coordinator: Coordinator?

    init(coordinator: Coordinator, repository: ItemRepository) {
        self.coordinator = coordinator
    }

    func didSelectItem(_ item: Item) {
        coordinator?.showDetail(for: item)
    }

    func openSettings() {
        coordinator?.showSettings()
    }
}

Repository Pattern

The repository pattern abstracts data sources and provides a clean API.

protocol ItemRepository {
    func fetchAll() async throws -> [Item]
    func fetch(by id: String) async throws -> Item?
    func search(_ query: String) async throws -> [Item]
    func save(_ item: Item) async throws
    func delete(_ id: String) async throws
}

class ItemRepositoryImpl: ItemRepository {
    let api: API
    let cache: Cache

    func fetchAll() async throws -> [Item] {
        if let cached = try? await cache.fetchAll(), !cached.isEmpty {
            return cached
        }
        let items = try await api.fetchAll()
        try await cache.save(items)
        return items
    }

    func search(_ query: String) async throws -> [Item] {
        try await api.search(query)
    }
}

Architectural Decision Guide

Pattern When to Use Benefits
MVVM SwiftUI apps, simple CRUD Minimal boilerplate, good testability
Clean Architecture Large apps, multiple teams Strict separation, independent layers
Coordinator Complex navigation flows View controllers know nothing about each other
Repository Multiple data sources Single data access point, easy Caching
Dependency Injection All projects Explicit dependencies, easy testing

Testing Architectures

import XCTest

class TaskListViewModelTests: XCTestCase {
    func testLoadTasks() async {
        let mockRepo = MockTaskRepository()
        mockRepo.mockTasks = [Task(id: UUID(), title: "Test", isCompleted: false, createdAt: Date())]

        let useCase = GetTasksUseCase(repository: mockRepo)
        let viewModel = TaskListViewModel(
            getTasksUseCase: useCase,
            toggleTaskUseCase: ToggleTaskUseCase(repository: mockRepo)
        )

        await viewModel.loadTasks()
        XCTAssertEqual(viewModel.tasks.count, 1)
        XCTAssertEqual(viewModel.tasks[0].title, "Test")
    }
}

class MockTaskRepository: TaskRepositoryProtocol {
    var mockTasks: [Task] = []
    var shouldThrowError = false

    func fetchAll() async throws -> [Task] {
        if shouldThrowError { throw URLError(.badServerResponse) }
        return mockTasks
    }

    func save(_ tasks: [Task]) async throws {
        if shouldThrowError { throw URLError(.badServerResponse) }
        mockTasks = tasks
    }

    func delete(_ id: UUID) async throws {
        mockTasks.removeAll { $0.id == id }
    }
}

Common Mistakes

  1. Massive View Controllers / Views: If a view has more than ~200 lines, extract logic into a view model.

  2. Skipping the Domain layer: Business logic in the view model is fine for simple apps, but as complexity grows, extract use cases.

  3. Over-engineering: Not every app needs Clean Architecture. Start with MVVM and add layers as complexity demands.

  4. Ignoring dependency injection direction: Dependencies should point inward (Presentation depends on Domain, not vice versa).

  5. Singletons everywhere: Singletons make testing impossible. Use dependency injection even for "global" services like logging and analytics.

Practice Questions

  1. What is the main benefit of separating code into Presentation, Domain, and Data layers?
  2. How does the Coordinator pattern remove navigation logic from views?
  3. Why should the Domain layer not depend on UIKit or SwiftUI?
  4. How does dependency injection improve testability?
  5. Challenge: Refactor a simple SwiftUI app into Clean Architecture with MVVM, a repository pattern with local/remote data sources, use cases, and a coordinator for navigation. Write unit tests for each layer.

FAQ

Should I use a third-party DI library like Swinject?

Manual DI is preferred for most projects — it is explicit and has no runtime overhead. Use Swinject or Needle only when you have complex dependency graphs across many modules.

How do I handle dependency injection in SwiftUI previews?

Create a mock dependencies struct that provides preview-friendly data. Inject it in the preview provider: TaskListView(viewModel: .mock).

Is Clean Architecture overkill for a simple app?

Yes. Start with MVVM. Add the domain and data layers only when you have multiple data sources, complex business logic, or multiple team members.

How do I handle shared state across view models?

Use an EnvironmentObject for truly global state (auth, theme). For other shared state, use a parent view model that passes data to children.

What is the difference between a use case and a repository?

A use case encapsulates a single business operation (get user, place order). A repository abstracts data access (fetch from API vs database). Use cases use repositories.

What's Next

Explore the broader Swift Ecosystem including Xcode, Swift Playgrounds, and community tools that make Swift development productive.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro