Understanding the basic concepts of MVVM + Clean Architecture in Swift
Clean Architecture was created by Uncle Bob, the author of the book Clean Code. In Clean Code, he discusses writing readable, maintainable code at the function and class level. Clean Architecture is about organizing entire applications. It’s like Clean Code, but zoomed out to the file and folder structure level.
The Problem
When we write C programs, we might organize our code like this:
main.c # Entry point
database.c # Database operations
network.c # Network operations
business_logic.c # Core algorithms
ui.c # User interface
This might work for simple programs, but problems arise:
- What if we change the database from SQLite to PostgreSQL?
- What if we want to test
business_logic.cwithout a real database? - What if UI code directly calls network functions?
The Solution
Clean Architecture solves this by creating layers with strict rules about who can talk to whom.
graph TB UI[UI Layer - SwiftUI Views] BL[Business Logic Layer - Your App's Rules] DATA[Data Layer - Database, Network, Files] UI -->|can call| BL BL -->|can call| DATA DATA -.->|cannot call| BL BL -.->|cannot call| UI
Data Access Layer
Used to talk to external systems such as databases, APIs, and files.
Examples:
- Fetching JSON from a REST API
- Reading/writing SQLite
- Saving images to disk
Important
This layer only knows how to get data.
Business Logic Layer
The brain of our app, containing the app’s rules.
Examples:
- A user can only save 100 photos in the free tier
- Photos must be compressed before uploading
- Calculate the total cost based on cart items
Components
- Data Structures (Models)
struct User {
let id: Int
let name: String
let email: String
}
struct Photo {
let id: String
let url: URL
let timestamp: Date
}- Use Cases (Actions)
A use case is one thing a user wants to do.
Examples:
- Upload a photo
- Get list of all photos
- Delete a photo
- Login user
Interface Layer
Handles UI and user interactions.
Components
- Views: What the users see and interact with
- ViewModels: The middleman between views and business logic
graph TD subgraph "Layer 3: Interface" View[SwiftUI View] ViewModel[ViewModel] end subgraph "Layer 2: Business Logic" UseCase[Use Case] Model[Model Structs] end subgraph "Layer 1: Data Access" API[API Client] DB[Database] end View -->|knows about| ViewModel ViewModel -->|knows about| UseCase ViewModel -->|knows about| Model UseCase -->|knows about| Model UseCase -->|knows about| API UseCase -->|knows about| DB API -.->|does NOT know about| UseCase DB -.->|does NOT know about| UseCase UseCase -.->|does NOT know about| ViewModel ViewModel -.->|does NOT know about| View
Example
graph TB subgraph "Layer 3: Interface" View[PhotoListView. swift] VM[PhotoListViewModel.swift] end subgraph "Layer 2: Business Logic" UC[GetPhotosUseCase.swift] Model[Photo.swift] end subgraph "Layer 1: Data Access" Protocol[PhotoRepository.swift<br/>protocol] Impl[NetworkPhotoRepository.swift<br/>implementation] end View --> VM VM --> UC UC --> Model UC --> Protocol Protocol --> Impl
Data Access Layer: The Repository
Here, we first define what operations are available, but not how they work:
protocol PhotoRepository {
// This function will get all photos from somewhere
// We don't specify WHERE—that's the point!
func fetchPhotos() async throws -> [Photo]
}Implementation
In the implementation, it actually fetches data from a network.
import Foundation
class NetworkPhotoRepository: PhotoRepository {
func fetchPhotos() async throws -> [Photo] {
// In a real app, you'd do this:
// 1. Create URLRequest
// 2. Use URLSession to fetch data
// 3. Parse JSON into Photo objects
// Simulate network delay (like sleep() in C, but non-blocking)
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
// Return dummy photos
return [
Photo(id: "1", title: "Sunset", url: "https://example.com/sunset.jpg"),
Photo(id: "2", title: "Mountain", url: "https://example.com/mountain.jpg"),
Photo(id: "3", title: "Ocean", url: "https://example.com/ocean.jpg")
]
}
}Why Separate Protocol from Implementation?
- Testing: You can create
MockPhotoRepositoryfor unit tests - Flexibility: Switch from API to database without changing business logic
- Dependency Inversion: Business logic depends on the protocol (abstraction), not the concrete implementation
graph LR UC[GetPhotosUseCase<br/>Business Logic] Protocol[PhotoRepository<br/>Protocol] Net[NetworkPhotoRepository] Mock[MockPhotoRepository] DB[DatabasePhotoRepository] UC -->|depends on| Protocol Protocol -.->|implemented by| Net Protocol -.->|implemented by| Mock Protocol -.->|implemented by| DB
Business Logic Layer: Models and Use Cases
The model is just a data structure with no dependencies:
import Foundation
struct Photo {
let id: String
let title: String
let url: String
}Use Cases
This represents one thing a user wants to do: get all photos in this case.
class GetPhotosUseCase {
// This use case needs a repository to fetch data
// We use the PROTOCOL, not a specific implementation
private let repository: PhotoRepository
init(repository: PhotoRepository) {
self.repository = repository
}
// Execute the use case
func execute() async throws -> [Photo] {
// This is where business logic would go
// For example:
// - Check if user is authenticated
// - Filter out inappropriate photos
// - Sort by date
// For now, just fetch from repository
let photos = try await repository.fetchPhotos()
// Example business rule: Only return photos with titles
let validPhotos = photos.filter { ! $0.title.isEmpty }
return validPhotos
}
}Interface Layer (MVVM): View and ViewModel
import Foundation
// @MainActor means "all code in this class runs on the main thread"
@MainActor
@Observable
class PhotoListViewModel {
var photos: [Photo] = []
var isLoading: Bool = false
var errorMessage: String? = nil
// The use case we'll call
private let getPhotosUseCase: GetPhotosUseCase
// Constructor - we pass in the use case
init(getPhotosUseCase: GetPhotosUseCase) {
self.getPhotosUseCase = getPhotosUseCase
}
// This function is called when user wants to load photos
func loadPhotos() {
isLoading = true
errorMessage = nil
// Task creates a new async context
Task {
do {
// Call the use case
let fetchedPhotos = try await getPhotosUseCase.execute()
// Update the property
// The View will automatically re-render
self.photos = fetchedPhotos
self. isLoading = false
} catch {
self. errorMessage = "Failed to load photos: \(error.localizedDescription)"
self.isLoading = false
}
}
}
}Why @MainActor?
iOS apps have a main thread (like
main()in C) that handles UI. You can’t update UI from other threads.
import SwiftUI
struct PhotoListView: View {
@State private var viewModel: PhotoListViewModel
init(viewModel: PhotoListViewModel) {
self.viewModel = viewModel
}
var body: some View {
NavigationView {
ZStack {
List(viewModel.photos, id: \.id) { photo in
VStack(alignment: .leading) {
Text(photo.title)
.font(.headline)
Text(photo.url)
.font(.caption)
.foregroundColor(. gray)
}
}
if viewModel.isLoading {
ProgressView("Loading...")
.frame(maxWidth: . infinity, maxHeight: .infinity)
.background(Color. black.opacity(0.2))
}
if let errorMessage = viewModel.errorMessage {
VStack {
Spacer()
Text(errorMessage)
.foregroundColor(.red)
.padding()
.background(Color.white)
.cornerRadius(8)
Spacer()
}
}
}
.navigationTitle("Photos")
.toolbar {
Button("Refresh") {
viewModel.loadPhotos()
}
}
}
.onAppear {
viewModel.loadPhotos()
}
}
}Entry Point: Creating the Dependency Chain
import SwiftUI
// Like main() in C
@main
struct PhotoApp: App {
var body: some Scene {
WindowGroup {
// 1. Create repository (Layer 1)
let repository: PhotoRepository = NetworkPhotoRepository()
// 2. Create use case with repository (Layer 2)
let getPhotosUseCase = GetPhotosUseCase(repository: repository)
// 3. Create ViewModel with use case (Layer 3)
let viewModel = PhotoListViewModel(getPhotosUseCase: getPhotosUseCase)
// 4. Create View with ViewModel (Layer 3)
PhotoListView(viewModel: viewModel)
}
}
}Flow
sequenceDiagram participant App as PhotoApp<br/>(main) participant Repo as NetworkPhotoRepository participant UC as GetPhotosUseCase participant VM as PhotoListViewModel participant View as PhotoListView App->>Repo: Create repository App->>UC: Create use case<br/>with repository App->>VM: Create ViewModel<br/>with use case App->>View: Create View<br/>with ViewModel Note over App,View: Now the app is running! View->>VM: User taps "Refresh"<br/>loadPhotos() VM->>UC: execute() UC->>Repo: fetchPhotos() Repo-->>UC: [Photo] array UC-->>VM: [Photo] array VM-->>View: Updates property View-->>View: Automatically re-renders