All Articles
CategoryArchitecture
Reading Time
20 min read
Published
2024-04-10
Word Count
2,483words

Grab a coffee — this one is a deep dive!

Clean Architecture on iOS

Summary

A practical guide to building scalable iOS apps with SOLID principles and Clean Architecture.

  • Clean Architecture has 4 layers: Entities, Use Cases, Interface Adapters, and Frameworks & Drivers.
  • The Dependency Rule: dependencies always flow from the outside in; the Domain layer imports no framework.
  • Each Use Case should have a single responsibility and stay under 50 lines (stated as a best practice).
  • Layers are connected via protocols; a DI Container manages every dependency centrally.
Clean Architecture on iOS

"Where do I put this code?" is one of the questions every iOS developer asks most often over the course of a career. Has your ViewController grown to 3,000 lines? Is your ViewModel making network calls? Is writing tests basically impossible? There's a single answer to all of these problems: Clean Architecture.

This architectural approach, introduced by Robert C. Martin (Uncle Bob), brings testability, independence, and maintainability to software development. In this guide, we'll apply Clean Architecture from an iOS perspective, step by step, with real production examples.

💡 Quick Note: This article draws on Uncle Bob's "Clean Architecture" book, Apple's official architectural guidance, and the production experience of companies like Uber and Spotify.

Table of Contents

  1. What Is Clean Architecture?
  2. The Dependency Rule: The One Non-Negotiable Rule
  3. Layer 1: Entities (Domain Layer)
  4. Layer 2: Use Cases (Application Layer)
  5. Layer 3: Interface Adapters (Presentation)
  6. Layer 4: Frameworks & Drivers (Infrastructure)
  7. Dependency Injection: Wiring the Layers Together
  8. SOLID Principles in Practice
  9. MVC vs MVVM vs VIPER vs Clean Architecture
  10. Testing Strategy
  11. Production Checklist

What Is Clean Architecture?

Clean Architecture splits your app into concentric layers. Each layer may depend only on layers further inward — it may never depend on layers further out. This simple rule is the foundation of the entire architecture.

swift
1┌---------------------------------------------┐
2│ Framework & Drivers (UI, DB) │
3│ ┌-------------------------------------┐ │
4│ │ Interface Adapters (ViewModels) │ │
5│ │ ┌-----------------------------┐ │ │
6│ │ │ Use Cases (Business) │ │ │
7│ │ │ ┌---------------------┐ │ │ │
8│ │ │ │ Entities (Core) │ │ │ │
9│ │ │ └---------------------┘ │ │ │
10│ │ └-------------------------┘ │ │
11│ └-------------------------------------┘ │
12└---------------------------------------------┘
13 
14 Dependency direction: Always OUTSIDE-IN →

Why does this matter so much? Because this structure means:

  • Even if the UI framework changes (UIKit → SwiftUI), your business logic is untouched
  • Even if the database changes (Core Data → SwiftData), your use cases are untouched
  • You can test every layer independently using mocks

External Resources:


The Dependency Rule: The One Non-Negotiable Rule

Clean Architecture has one rule: dependencies always flow from the outside in. Inner layers must not even be aware that outer layers exist.

The moment you violate this rule, the whole architecture collapses. So how is this enforced in practice? With protocols:

swift
1// ❌ WRONG: the Domain layer depends on Infrastructure
2// Domain/UseCases/FetchUsersUseCase.swift
3import CoreData // ← A framework import in the Domain layer = RULE VIOLATION!
4 
5class FetchUsersUseCase {
6 let context: NSManagedObjectContext // ← CoreData dependency
7 
8 func execute() -> [User] {
9 let request = NSFetchRequest<UserEntity>(entityName: "User")
10 return try! context.fetch(request).map { $0.toDomain() }
11 }
12}
13 
14// ✅ RIGHT: the Domain layer only defines a protocol
15// Domain/Protocols/UserRepositoryProtocol.swift
16protocol UserRepositoryProtocol {
17 func fetchAll() async throws -> [User]
18 func save(_ user: User) async throws
19 func delete(id: UUID) async throws
20}
21 
22// Domain/UseCases/FetchUsersUseCase.swift
23// No import - pure Swift!
24final class FetchUsersUseCase {
25 private let repository: UserRepositoryProtocol // ← Protocol only
26 
27 init(repository: UserRepositoryProtocol) {
28 self.repository = repository
29 }
30 
31 func execute(sortBy: SortOption = .name) async throws -> [User] {
32 let users = try await repository.fetchAll()
33 return sort(users, by: sortBy)
34 }
35}

Layer 1: Entities (Domain Layer)

The innermost layer — the heart of the app. It holds business rules and has no dependency on any framework. Pure Swift.

swift
1// Domain/Entities/User.swift - PURE SWIFT, no import!
2struct User: Identifiable, Equatable, Sendable {
3 let id: UUID
4 var name: String
5 var email: String
6 var avatar: URL?
7 let createdAt: Date
8 var role: Role
9 
10 enum Role: String, Sendable {
11 case admin, editor, viewer
12 }
13 
14 // Business logic lives on the Entity
15 func validate() -> ValidationResult {
16 var errors: [String] = []
17 
18 if name.trimmingCharacters(in: .whitespaces).isEmpty {
19 errors.append("Name cannot be empty")
20 }
21 if !email.contains("@") || !email.contains(".") {
22 errors.append("Invalid email format")
23 }
24 if name.count > 100 {
25 errors.append("Name cannot be longer than 100 characters")
26 }
27 
28 return errors.isEmpty ? .valid : .invalid(errors)
29 }
30 
31 enum ValidationResult {
32 case valid
33 case invalid([String])
34 }
35}
36 
37// Domain/Entities/Product.swift
38struct Product: Identifiable, Equatable, Sendable {
39 let id: UUID
40 var name: String
41 var description: String
42 var price: Decimal
43 var stock: Int
44 var category: Category
45 let createdAt: Date
46 
47 enum Category: String, CaseIterable, Sendable {
48 case electronics, clothing, food, books
49 }
50 
51 // Business rule: stock check
52 var isAvailable: Bool { stock > 0 }
53 
54 // Business rule: discount calculation
55 func discountedPrice(percentage: Decimal) -> Decimal {
56 guard percentage > 0 && percentage <= 100 else { return price }
57 return price * (1 - percentage / 100)
58 }
59}
🎯 Best Practice: Entities should be immutable where possible (prefer let). Where mutation is needed, create a new instance instead. This gives you thread safety and easier debugging.

Layer 2: Use Cases (Application Layer)

Use Cases define what the app does. Each Use Case represents a single operation (Single Responsibility).

swift
1// Domain/UseCases/FetchProductsUseCase.swift
2protocol FetchProductsUseCaseProtocol: Sendable {
3 func execute(filter: ProductFilter?) async throws -> [Product]
4}
5 
6struct ProductFilter: Sendable {
7 var category: Product.Category?
8 var minPrice: Decimal?
9 var maxPrice: Decimal?
10 var inStockOnly: Bool = true
11}
12 
13final class FetchProductsUseCase: FetchProductsUseCaseProtocol {
14 private let repository: ProductRepositoryProtocol
15 
16 init(repository: ProductRepositoryProtocol) {
17 self.repository = repository
18 }
19 
20 func execute(filter: ProductFilter? = nil) async throws -> [Product] {
21 var products = try await repository.fetchAll()
22 
23 if let filter {
24 if let category = filter.category {
25 products = products.filter { $0.category == category }
26 }
27 if let minPrice = filter.minPrice {
28 products = products.filter { $0.price >= minPrice }
29 }
30 if let maxPrice = filter.maxPrice {
31 products = products.filter { $0.price <= maxPrice }
32 }
33 if filter.inStockOnly {
34 products = products.filter { $0.isAvailable }
35 }
36 }
37 
38 return products.sorted { $0.createdAt > $1.createdAt }
39 }
40}
41 
42// Each Use Case does ONE thing - keep it under 50 lines!
43final class AddToCartUseCase {
44 private let cartRepository: CartRepositoryProtocol
45 private let productRepository: ProductRepositoryProtocol
46 
47 init(cart: CartRepositoryProtocol, product: ProductRepositoryProtocol) {
48 self.cartRepository = cart
49 self.productRepository = product
50 }
51 
52 func execute(productId: UUID, quantity: Int) async throws {
53 let product = try await productRepository.fetch(id: productId)
54 
55 guard product.isAvailable else {
56 throw DomainError.outOfStock
57 }
58 guard product.stock >= quantity else {
59 throw DomainError.insufficientStock(available: product.stock)
60 }
61 
62 try await cartRepository.addItem(productId: productId, quantity: quantity)
63 }
64}

Layer 3: Interface Adapters (Presentation)

ViewModels and Presenters live in this layer. They call Use Cases and translate the results into a format the UI can consume.

swift
1// Presentation/ViewModels/ProductListViewModel.swift
2@MainActor
3final class ProductListViewModel: ObservableObject {
4 @Published private(set) var products: [ProductDisplayItem] = []
5 @Published private(set) var isLoading = false
6 @Published private(set) var error: String?
7 @Published var selectedCategory: Product.Category?
8 
9 private let fetchProductsUseCase: FetchProductsUseCaseProtocol
10 
11 // Dependency Injection via init
12 init(fetchProductsUseCase: FetchProductsUseCaseProtocol) {
13 self.fetchProductsUseCase = fetchProductsUseCase
14 }
15 
16 func loadProducts() async {
17 isLoading = true
18 error = nil
19 
20 do {
21 let filter = ProductFilter(category: selectedCategory)
22 let domainProducts = try await fetchProductsUseCase.execute(filter: filter)
23 
24 // Domain → Presentation mapping
25 products = domainProducts.map { product in
26 ProductDisplayItem(
27 id: product.id,
28 name: product.name,
29 formattedPrice: formatPrice(product.price),
30 availability: product.isAvailable ? "In stock" : "Out of stock",
31 availabilityColor: product.isAvailable ? .green : .red,
32 categoryBadge: product.category.rawValue.capitalized
33 )
34 }
35 } catch {
36 self.error = mapError(error)
37 }
38 
39 isLoading = false
40 }
41 
42 // UI-specific formatting
43 private func formatPrice(_ price: Decimal) -> String {
44 let formatter = NumberFormatter()
45 formatter.numberStyle = .currency
46 formatter.currencyCode = "TRY"
47 return formatter.string(from: price as NSDecimalNumber) ?? "\(price) ₺"
48 }
49 
50 private func mapError(_ error: Error) -> String {
51 switch error {
52 case DomainError.outOfStock: return "Product is out of stock"
53 case is URLError: return "Check your internet connection"
54 default: return "An unexpected error occurred"
55 }
56 }
57}
58 
59// Presentation model - UI-specific
60struct ProductDisplayItem: Identifiable {
61 let id: UUID
62 let name: String
63 let formattedPrice: String
64 let availability: String
65 let availabilityColor: Color
66 let categoryBadge: String
67}

Layer 4: Frameworks & Drivers (Infrastructure)

The outermost layer. This is where external dependencies like the database, networking, and the UI framework live.

swift
1// Data/Repositories/ProductRepository.swift
2final class ProductRepository: ProductRepositoryProtocol {
3 private let apiClient: APIClientProtocol
4 private let cache: CacheProtocol
5 
6 init(apiClient: APIClientProtocol, cache: CacheProtocol) {
7 self.apiClient = apiClient
8 self.cache = cache
9 }
10 
11 func fetchAll() async throws -> [Product] {
12 // Check the cache first
13 if let cached: [Product] = cache.get(key: "products") {
14 return cached
15 }
16 
17 // Fetch from the API
18 let response: ProductListResponse = try await apiClient.request(
19 endpoint: .products
20 )
21 
22 // DTO → Domain Entity conversion
23 let products = response.items.map { dto in
24 Product(
25 id: dto.id,
26 name: dto.name,
27 description: dto.desc,
28 price: Decimal(dto.priceInCents) / 100,
29 stock: dto.stockCount,
30 category: Product.Category(rawValue: dto.category) ?? .electronics,
31 createdAt: dto.createdAt
32 )
33 }
34 
35 // Save to cache
36 cache.set(key: "products", value: products, expiry: .minutes(5))
37 
38 return products
39 }
40 
41 func fetch(id: UUID) async throws -> Product {
42 // Cache first, then the API...
43 let response: ProductDTO = try await apiClient.request(endpoint: .product(id: id))
44 return response.toDomain()
45 }
46}

Dependency Injection: Wiring the Layers Together

We use a DI Container to wire the layers together. This keeps every dependency managed in a single place.

swift
1// App/DI/DependencyContainer.swift
2@MainActor
3final class DependencyContainer {
4 static let shared = DependencyContainer()
5 
6 // Infrastructure
7 private lazy var apiClient: APIClientProtocol = URLSessionAPIClient()
8 private lazy var cache: CacheProtocol = InMemoryCache()
9 
10 // Repositories
11 private lazy var productRepository: ProductRepositoryProtocol = {
12 ProductRepository(apiClient: apiClient, cache: cache)
13 }()
14 
15 // Use Cases
16 func makeFetchProductsUseCase() -> FetchProductsUseCaseProtocol {
17 FetchProductsUseCase(repository: productRepository)
18 }
19 
20 // ViewModels
21 func makeProductListViewModel() -> ProductListViewModel {
22 ProductListViewModel(fetchProductsUseCase: makeFetchProductsUseCase())
23 }
24}
25 
26// Usage in SwiftUI
27struct ProductListView: View {
28 @StateObject private var viewModel = DependencyContainer.shared.makeProductListViewModel()
29 
30 var body: some View {
31 List(viewModel.products) { item in
32 ProductRow(item: item)
33 }
34 .task { await viewModel.loadProducts() }
35 }
36}

SOLID Principles in Practice

Clean Architecture goes hand in hand with the SOLID principles. Let's look at each one in an iOS context:

Principle
Description
iOS Example
Single Responsibility
Each class has one responsibility
The ViewModel only manages UI state
Open/Closed
Open for extension, closed for modification
Protocol + extension pattern
Liskov Substitution
Subtypes must be substitutable for their base types
Mock repository = real repository
Interface Segregation
No unnecessary dependencies
Small, focused protocols
Dependency Inversion
Depend on abstractions, not concretions
Use Case → Protocol ← Repository

MVC vs MVVM vs VIPER vs Clean Architecture

Criterion
MVC
MVVM
VIPER
Clean Architecture
Complexity
Low
Medium
High
Medium-High
Testability
Low
Medium
High
Very High
Scalability
Low
Medium
High
Very High
Learning curve
Easy
Medium
Hard
Medium
Boilerplate
Low
Medium
High
Medium
Framework independence
None
Low
Partial
Full
💡 Pro Tip: For small projects (5-10 screens), MVVM is enough. For 20+ screens with teams of 3 or more, Clean Architecture pulls ahead — it delivers all of VIPER's benefits with less boilerplate.

Testing Strategy

Clean Architecture's biggest advantage: every layer can be tested in isolation.

swift
1// Tests/UseCases/FetchProductsUseCaseTests.swift
2final class FetchProductsUseCaseTests: XCTestCase {
3 
4 func test_execute_returnsFilteredProducts() async throws {
5 // Arrange - mock repository
6 let mockRepo = MockProductRepository()
7 mockRepo.stubbedProducts = [
8 Product(id: UUID(), name: "iPhone", description: "", price: 100, stock: 5, category: .electronics, createdAt: Date()),
9 Product(id: UUID(), name: "T-Shirt", description: "", price: 50, stock: 0, category: .clothing, createdAt: Date()),
10 ]
11 
12 let sut = FetchProductsUseCase(repository: mockRepo)
13 
14 // Act
15 let filter = ProductFilter(category: .electronics, inStockOnly: true)
16 let result = try await sut.execute(filter: filter)
17 
18 // Assert
19 XCTAssertEqual(result.count, 1)
20 XCTAssertEqual(result.first?.name, "iPhone")
21 }
22}
23 
24// Mock - fully controllable
25class MockProductRepository: ProductRepositoryProtocol {
26 var stubbedProducts: [Product] = []
27 var fetchAllCallCount = 0
28 
29 func fetchAll() async throws -> [Product] {
30 fetchAllCallCount += 1
31 return stubbedProducts
32 }
33 
34 func fetch(id: UUID) async throws -> Product {
35 guard let product = stubbedProducts.first(where: { $0.id == id }) else {
36 throw DomainError.notFound
37 }
38 return product
39 }
40}

Production Checklist

🔑 Key Takeaways From This Article

  1. The Dependency Rule: dependencies always flow from the outside in
  2. The Domain layer is pure Swift: never import any Apple framework there
  3. Each Use Case does one thing: keep it under 50 lines
  4. Protocol-first design: depend on protocols, not concrete classes
  5. Use a DI Container: manage dependencies from a single place
  6. DTO ↔ Domain mapping: converting data across layer boundaries is essential
  7. Think test-first: every layer should be testable in isolation

Easter Egg

Gizli bir bilgi buldun!

Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?

Okuyucu Ödülü

Learning Clean Architecture at this depth is a real achievement! You can now field architecture questions confidently in interviews, refactor existing projects, and build new ones on solid foundations. Here's something just for you:

ALTIN İPUCU

Bu yazının en değerli bilgisi

Bu ipucu, yazının en önemli çıkarımını içeriyor.

Tags

#clean-architecture#solid#ios#architecture#design-patterns
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share