"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
- What Is Clean Architecture?
- The Dependency Rule: The One Non-Negotiable Rule
- Layer 1: Entities (Domain Layer)
- Layer 2: Use Cases (Application Layer)
- Layer 3: Interface Adapters (Presentation)
- Layer 4: Frameworks & Drivers (Infrastructure)
- Dependency Injection: Wiring the Layers Together
- SOLID Principles in Practice
- MVC vs MVVM vs VIPER vs Clean Architecture
- Testing Strategy
- 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.
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:
1// ❌ WRONG: the Domain layer depends on Infrastructure2// Domain/UseCases/FetchUsersUseCase.swift3import CoreData // ← A framework import in the Domain layer = RULE VIOLATION!4 5class FetchUsersUseCase {6 let context: NSManagedObjectContext // ← CoreData dependency7 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 protocol15// Domain/Protocols/UserRepositoryProtocol.swift16protocol UserRepositoryProtocol {17 func fetchAll() async throws -> [User]18 func save(_ user: User) async throws19 func delete(id: UUID) async throws20}21 22// Domain/UseCases/FetchUsersUseCase.swift23// No import - pure Swift!24final class FetchUsersUseCase {25 private let repository: UserRepositoryProtocol // ← Protocol only26 27 init(repository: UserRepositoryProtocol) {28 self.repository = repository29 }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.
1// Domain/Entities/User.swift - PURE SWIFT, no import!2struct User: Identifiable, Equatable, Sendable {3 let id: UUID4 var name: String5 var email: String6 var avatar: URL?7 let createdAt: Date8 var role: Role9 10 enum Role: String, Sendable {11 case admin, editor, viewer12 }13 14 // Business logic lives on the Entity15 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 valid33 case invalid([String])34 }35}36 37// Domain/Entities/Product.swift38struct Product: Identifiable, Equatable, Sendable {39 let id: UUID40 var name: String41 var description: String42 var price: Decimal43 var stock: Int44 var category: Category45 let createdAt: Date46 47 enum Category: String, CaseIterable, Sendable {48 case electronics, clothing, food, books49 }50 51 // Business rule: stock check52 var isAvailable: Bool { stock > 0 }53 54 // Business rule: discount calculation55 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).
1// Domain/UseCases/FetchProductsUseCase.swift2protocol 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 = true11}12 13final class FetchProductsUseCase: FetchProductsUseCaseProtocol {14 private let repository: ProductRepositoryProtocol15 16 init(repository: ProductRepositoryProtocol) {17 self.repository = repository18 }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: CartRepositoryProtocol45 private let productRepository: ProductRepositoryProtocol46 47 init(cart: CartRepositoryProtocol, product: ProductRepositoryProtocol) {48 self.cartRepository = cart49 self.productRepository = product50 }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.outOfStock57 }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.
1// Presentation/ViewModels/ProductListViewModel.swift2@MainActor3final class ProductListViewModel: ObservableObject {4 @Published private(set) var products: [ProductDisplayItem] = []5 @Published private(set) var isLoading = false6 @Published private(set) var error: String?7 @Published var selectedCategory: Product.Category?8 9 private let fetchProductsUseCase: FetchProductsUseCaseProtocol10 11 // Dependency Injection via init12 init(fetchProductsUseCase: FetchProductsUseCaseProtocol) {13 self.fetchProductsUseCase = fetchProductsUseCase14 }15 16 func loadProducts() async {17 isLoading = true18 error = nil19 20 do {21 let filter = ProductFilter(category: selectedCategory)22 let domainProducts = try await fetchProductsUseCase.execute(filter: filter)23 24 // Domain → Presentation mapping25 products = domainProducts.map { product in26 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.capitalized33 )34 }35 } catch {36 self.error = mapError(error)37 }38 39 isLoading = false40 }41 42 // UI-specific formatting43 private func formatPrice(_ price: Decimal) -> String {44 let formatter = NumberFormatter()45 formatter.numberStyle = .currency46 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-specific60struct ProductDisplayItem: Identifiable {61 let id: UUID62 let name: String63 let formattedPrice: String64 let availability: String65 let availabilityColor: Color66 let categoryBadge: String67}Layer 4: Frameworks & Drivers (Infrastructure)
The outermost layer. This is where external dependencies like the database, networking, and the UI framework live.
1// Data/Repositories/ProductRepository.swift2final class ProductRepository: ProductRepositoryProtocol {3 private let apiClient: APIClientProtocol4 private let cache: CacheProtocol5 6 init(apiClient: APIClientProtocol, cache: CacheProtocol) {7 self.apiClient = apiClient8 self.cache = cache9 }10 11 func fetchAll() async throws -> [Product] {12 // Check the cache first13 if let cached: [Product] = cache.get(key: "products") {14 return cached15 }16 17 // Fetch from the API18 let response: ProductListResponse = try await apiClient.request(19 endpoint: .products20 )21 22 // DTO → Domain Entity conversion23 let products = response.items.map { dto in24 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.createdAt32 )33 }34 35 // Save to cache36 cache.set(key: "products", value: products, expiry: .minutes(5))37 38 return products39 }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.
1// App/DI/DependencyContainer.swift2@MainActor3final class DependencyContainer {4 static let shared = DependencyContainer()5 6 // Infrastructure7 private lazy var apiClient: APIClientProtocol = URLSessionAPIClient()8 private lazy var cache: CacheProtocol = InMemoryCache()9 10 // Repositories11 private lazy var productRepository: ProductRepositoryProtocol = {12 ProductRepository(apiClient: apiClient, cache: cache)13 }()14 15 // Use Cases16 func makeFetchProductsUseCase() -> FetchProductsUseCaseProtocol {17 FetchProductsUseCase(repository: productRepository)18 }19 20 // ViewModels21 func makeProductListViewModel() -> ProductListViewModel {22 ProductListViewModel(fetchProductsUseCase: makeFetchProductsUseCase())23 }24}25 26// Usage in SwiftUI27struct ProductListView: View {28 @StateObject private var viewModel = DependencyContainer.shared.makeProductListViewModel()29 30 var body: some View {31 List(viewModel.products) { item in32 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.
1// Tests/UseCases/FetchProductsUseCaseTests.swift2final class FetchProductsUseCaseTests: XCTestCase {3 4 func test_execute_returnsFilteredProducts() async throws {5 // Arrange - mock repository6 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 // Act15 let filter = ProductFilter(category: .electronics, inStockOnly: true)16 let result = try await sut.execute(filter: filter)17 18 // Assert19 XCTAssertEqual(result.count, 1)20 XCTAssertEqual(result.first?.name, "iPhone")21 }22}23 24// Mock - fully controllable25class MockProductRepository: ProductRepositoryProtocol {26 var stubbedProducts: [Product] = []27 var fetchAllCallCount = 028 29 func fetchAll() async throws -> [Product] {30 fetchAllCallCount += 131 return stubbedProducts32 }33 34 func fetch(id: UUID) async throws -> Product {35 guard let product = stubbedProducts.first(where: { $0.id == id }) else {36 throw DomainError.notFound37 }38 return product39 }40}Production Checklist
🔑 Key Takeaways From This Article
- The Dependency Rule: dependencies always flow from the outside in
- The Domain layer is pure Swift: never import any Apple framework there
- Each Use Case does one thing: keep it under 50 lines
- Protocol-first design: depend on protocols, not concrete classes
- Use a DI Container: manage dependencies from a single place
- DTO ↔ Domain mapping: converting data across layer boundaries is essential
- 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
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.

