Skip to content
advanced Phase 3 · App Lifecycle & Architecture

Clean Architecture

Structure iOS apps with layers: presentation, domain, and data for testability and maintainability.

1h
3 problems
Topic Progress 0%

Layer Separation

What Is Clean Architecture?

Clean Architecture, proposed by Robert C. Martin, organizes code into concentric layers where dependencies point inward. The inner layers have no knowledge of outer layers.

Presentation Layer (Views, ViewModels)
        |
   Domain Layer (Entities, Use Cases, Repository Protocols)
        |
   Data Layer (Repository Implementations, API, Database)

The Dependency Rule

The most important rule: source code dependencies must point inward only. The Domain layer knows nothing about the Presentation or Data layers.

// Domain Layer - no imports from Data or Presentation
protocol UserRepository {
    func fetchUsers() async throws -> [User]
    func saveUser(_ user: User) async throws
}

// Data Layer - imports Domain
class ApiUserRepository: UserRepository {
    func fetchUsers() async throws -> [User] {
        let response: [UserDTO] = try await apiClient.request(.users)
        return response.map { $0.toDomain() }
    }
}

// Presentation Layer - imports Domain
@Observable
class UserListViewModel {
    private let repository: UserRepository
    
    init(repository: UserRepository) {
        self.repository = repository
    }
}

Why Clean Architecture?

  • Testability: Each layer can be tested independently with mocks
  • Maintainability: Changes in one layer do not ripple to others
  • Scalability: New features follow the same pattern
  • Framework independence: Domain layer has no UIKit or SwiftUI imports

Layer Responsibilities

Layer Contains Depends On
Presentation Views, ViewModels, View state Domain only
Domain Entities, Use Cases, Repository protocols Nothing (innermost)
Data Repository implementations, API clients, DB Domain only

Domain Layer

Entities

Entities are the core business objects. They are pure Swift structs with no framework dependencies:

struct User: Equatable, Identifiable {
    let id: UUID
    let name: String
    let email: String
    let avatarURL: URL?
    let createdAt: Date
}

struct Product: Equatable, Identifiable {
    let id: UUID
    let name: String
    let price: Decimal
    let category: Category
    let imageURL: URL
}

enum Category: String, CaseIterable {
    case electronics, clothing, food, books
}

Use Cases

Use cases encapsulate business logic. Each use case performs a single action:

protocol FetchUsersUseCaseProtocol {
    func execute() async throws -> [User]
}

struct FetchUsersUseCase: FetchUsersUseCaseProtocol {
    private let repository: UserRepository
    
    init(repository: UserRepository) {
        self.repository = repository
    }
    
    func execute() async throws -> [User] {
        let users = try await repository.fetchUsers()
        return users.sorted(by: { $0.name < $1.name })
    }
}

Repository Protocols

Repositories define the contract for data access without specifying the implementation:

protocol UserRepository {
    func fetchUsers() async throws -> [User]
    func fetchUser(id: UUID) async throws -> User?
    func saveUser(_ user: User) async throws
    func deleteUser(id: UUID) async throws
}

protocol ProductRepository {
    func fetchProducts(category: Category?) async throws -> [Product]
    func searchProducts(query: String) async throws -> [Product]
}

Use Case Composition

Complex operations compose multiple use cases:

protocol RegisterUserUseCaseProtocol {
    func execute(name: String, email: String, password: String) async throws -> User
}

struct RegisterUserUseCase: RegisterUserUseCaseProtocol {
    private let authRepository: AuthRepository
    private let userRepository: UserRepository
    private let analyticsService: AnalyticsService
    
    func execute(name: String, email: String, password: String) async throws -> User {
        let authResult = try await authRepository.register(email: email, password: password)
        let user = User(id: authResult.userId, name: name, email: email, avatarURL: nil, createdAt: Date())
        try await userRepository.saveUser(user)
        analyticsService.track(event: "user_registered", properties: ["userId": user.id.uuidString])
        return user
    }
}

The use case coordinates multiple services without the ViewModel knowing the implementation details.

Data Layer

Repository Implementations

The Data layer implements Domain repository protocols with actual data sources:

class ApiUserRepository: UserRepository {
    private let apiClient: ApiClient
    private let cache: UserCache
    
    init(apiClient: ApiClient, cache: UserCache) {
        self.apiClient = apiClient
        self.cache = cache
    }
    
    func fetchUsers() async throws -> [User] {
        // Try cache first
        if let cached = try? await cache.getUsers(), !cached.isEmpty {
            return cached
        }
        
        // Fetch from API
        let dtos: [UserDTO] = try await apiClient.request(.users)
        let users = dtos.map { $0.toDomain() }
        
        // Update cache
        try? await cache.saveUsers(users)
        return users
    }
    
    func saveUser(_ user: User) async throws {
        let dto = UserDTO.from(domain: user)
        try await apiClient.request(.saveUser(dto))
        try? await cache.saveUser(user)
    }
}

Data Transfer Objects (DTOs)

DTOs handle the mapping between network/database format and domain entities:

struct UserDTO: Codable {
    let id: String
    let full_name: String
    let email_address: String
    let avatar: String?
    let created_at: String
    
    func toDomain() -> User {
        User(
            id: UUID(uuidString: id) ?? UUID(),
            name: full_name,
            email: email_address,
            avatarURL: avatar.flatMap(URL.init(string:)),
            createdAt: ISO8601DateFormatter().date(from: created_at) ?? Date()
        )
    }
    
    static func from(domain user: User) -> UserDTO {
        UserDTO(
            id: user.id.uuidString,
            full_name: user.name,
            email_address: user.email,
            avatar: user.avatarURL?.absoluteString,
            created_at: ISO8601DateFormatter().string(from: user.createdAt)
        )
    }
}

Presentation Layer

The Presentation layer uses ViewModels that depend on Domain use cases:

@Observable
class UserListViewModel {
    var users: [User] = []
    var isLoading = false
    var error: String?
    
    private let fetchUsers: FetchUsersUseCaseProtocol
    
    init(fetchUsers: FetchUsersUseCaseProtocol) {
        self.fetchUsers = fetchUsers
    }
    
    func loadUsers() async {
        isLoading = true
        defer { isLoading = false }
        do {
            users = try await fetchUsers.execute()
        } catch {
            self.error = error.localizedDescription
        }
    }
}

struct UserListView: View {
    @State private var viewModel: UserListViewModel
    
    init(repository: UserRepository) {
        let useCase = FetchUsersUseCase(repository: repository)
        _viewModel = State(initialValue: UserListViewModel(fetchUsers: useCase))
    }
    
    var body: some View {
        List(viewModel.users) { user in
            Text(user.name)
        }
    }
}

Dependency Graph

Wire everything together at the app level:

@main
struct MyApp: App {
    var body: some Scene {
            WindowGroup {
                let apiClient = ApiClient(baseURL: "https://api.example.com")
                let cache = UserDefaultsUserCache()
                let repository = ApiUserRepository(apiClient: apiClient, cache: cache)
                let useCase = FetchUsersUseCase(repository: repository)
                let viewModel = UserListViewModel(fetchUsers: useCase)
                UserListView(viewModel: viewModel)
            }
    }
}

For production apps, use a dependency injection container to manage this graph.

Quiz

1. What is the dependency rule in Clean Architecture?

Question 1 options

2. What lives in the Domain layer?

Question 2 options

3. What is a DTO (Data Transfer Object)?

Question 3 options

4. Why does the Domain layer have no framework imports?

Question 4 options

5. What does a Use Case represent?

Question 5 options

Flashcards

Question

What is the dependency rule?

Answer

Source code dependencies point inward only. Inner layers (Domain) know nothing about outer layers (Data, Presentation).

Question

What are the three main layers?

Answer

Domain (entities, use cases, protocols), Data (repository implementations, API), Presentation (views, viewmodels).

Question

What is a DTO?

Answer

A Data Transfer Object that maps between external data formats and clean domain entities.

Question

What does a Use Case encapsulate?

Answer

A single business action or operation, coordinating multiple services without the ViewModel knowing details.

Question

Why use repository protocols?

Answer

They define the data access contract, allowing the Domain layer to depend on abstractions, not implementations.

Revision Notes

Key Takeaways

  • 1. Clean Architecture separates Domain, Data, and Presentation layers
  • 2. The dependency rule ensures inner layers have no knowledge of outer layers
  • 3. Domain entities and use cases are pure Swift with no framework dependencies
  • 4. DTOs map between external formats and domain entities
  • 5. Use cases encapsulate single business actions for testability

Interview Tips

  • Explain the dependency rule and why it matters for testability
  • Walk through the three layers and what each contains
  • Discuss how DTOs bridge API responses to domain entities
  • Be ready to design a clean architecture for a given feature

Cheat Sheet

Clean Architecture: Domain <- Data <- Presentation.
Dependency Rule: dependencies point inward only.
Domain: entities, use cases, repository protocols.
Data: repository implementations, DTOs, API clients.
Presentation: ViewModels, Views.
Use Cases encapsulate single business actions.