Swift App Architecture — MVVM, Clean Architecture, and Design Patterns
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
Massive View Controllers / Views: If a view has more than ~200 lines, extract logic into a view model.
Skipping the Domain layer: Business logic in the view model is fine for simple apps, but as complexity grows, extract use cases.
Over-engineering: Not every app needs Clean Architecture. Start with MVVM and add layers as complexity demands.
Ignoring dependency injection direction: Dependencies should point inward (Presentation depends on Domain, not vice versa).
Singletons everywhere: Singletons make testing impossible. Use dependency injection even for "global" services like logging and analytics.
Practice Questions
- What is the main benefit of separating code into Presentation, Domain, and Data layers?
- How does the Coordinator pattern remove navigation logic from views?
- Why should the Domain layer not depend on UIKit or SwiftUI?
- How does dependency injection improve testability?
- 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
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