"Bu kodu nereye koyayım?" sorusu, her iOS geliştiricinin kariyerinde en sık sorduğu sorulardan biri. ViewController 3000 satır mı oldu? ViewModel'de network çağrısı mı yapılıyor? Test yazmak imkansız mı? Bu sorunların hepsinin tek bir cevabı var: Clean Architecture.
Robert C. Martin'in (Uncle Bob) ortaya koyduğu bu mimari yaklaşım, yazılım geliştirmede test edilebilirlik, bağımsızlık ve sürdürülebilirlik sağlıyor. Bu rehberde Clean Architecture'ı iOS perspektifinden, gerçek production örnekleriyle ve adım adım uygulayacağız.
💡 Hızlı Not: Bu yazı Uncle Bob'un "Clean Architecture" kitabı, Apple'ın resmi mimari önerileri ve Uber, Spotify gibi şirketlerin production deneyimlerinden derlendi.
İçindekiler
- Clean Architecture Nedir?
- Dependency Rule: Tek ve Tartışılmaz Kural
- Katman 1: Entities (Domain Layer)
- Katman 2: Use Cases (Application Layer)
- Katman 3: Interface Adapters (Presentation)
- Katman 4: Framework & Drivers (Infrastructure)
- Dependency Injection: Katmanları Birleştirmek
- SOLID Prensipleri Pratikte
- MVC vs MVVM vs VIPER vs Clean Architecture
- Testing Stratejisi
- Production Checklist
Clean Architecture Nedir? {#clean-architecture-nedir}
Clean Architecture, uygulamanızı konsantrik katmanlara ayırır. Her katman, sadece daha içteki katmanlara bağımlı olabilir - dıştaki katmanlara bağımlı olamaz. Bu basit kural, tüm mimarinin temelidir.
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 Bağımlılık yönü: Her zaman DIŞTAN İÇE →Neden bu kadar önemli? Çünkü bu yapı sayesinde:
- UI framework değişse bile: iş mantığın etkilenmez (UIKit → SwiftUI geçişi)
- Database değişse bile: use case'lerin etkilenmez (Core Data → SwiftData)
- Her katmanı bağımsız test: edebilirsin (mock'larla)
Dış Kaynaklar:
Dependency Rule: Tek ve Tartışılmaz Kural {#dependency-rule}
Clean Architecture'ın tek kuralı: Bağımlılıklar her zaman dıştan içe doğru akar. İç katmanlar, dış katmanların varlığından haberdar bile olmamalı.
Bu kuralı ihlal ettiğin an, tüm mimari çöker. Peki pratikte bu nasıl uygulanır? Protocol'ler ile:
swift
1// ❌ YANLIŞ: Domain katmanı, Infrastructure'a bağımlı2// Domain/UseCases/FetchUsersUseCase.swift3import CoreData // ← Domain katmanında framework import = KURAL İHLALİ!4 5class FetchUsersUseCase {6 let context: NSManagedObjectContext // ← CoreData bağımlılığı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// ✅ DOĞRU: Domain katmanı sadece protokol tanımlar15// 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// import YOK - pure Swift!24final class FetchUsersUseCase {25 private let repository: UserRepositoryProtocol // ← Sadece protokol26 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}Katman 1: Entities (Domain Layer) {#entities}
En içteki katman - uygulamanın kalbi. İş mantığı kurallarını içerir. Hiçbir framework'e bağımlı değildir. Saf Swift.
swift
1// Domain/Entities/User.swift - PURE SWIFT, import YOK!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 // İş mantığı Entity'de yaşar15 func validate() -> ValidationResult {16 var errors: [String] = []17 18 if name.trimmingCharacters(in: .whitespaces).isEmpty {19 errors.append("İsim boş olamaz")20 }21 if !email.contains("@") || !email.contains(".") {22 errors.append("Geçersiz email formatı")23 }24 if name.count > 100 {25 errors.append("İsim 100 karakterden uzun olamaz")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 // İş kuralı: stok kontrolü52 var isAvailable: Bool { stock > 0 }53 54 // İş kuralı: indirim hesaplama55 func discountedPrice(percentage: Decimal) -> Decimal {56 guard percentage > 0 && percentage <= 100 else { return price }57 return price * (1 - percentage / 100)58 }59}🎯 Best Practice: Entity'ler immutable olmalı (mümkünse let kullan). Mutation gereken yerlerde yeni instance oluştur. Bu, thread safety ve debugging kolaylığı sağlar.Katman 2: Use Cases (Application Layer) {#use-cases}
Use Case'ler, uygulamanın ne yapacağını tanımlar. Her Use Case tek bir operasyonu temsil eder (Single Responsibility).
swift
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// Her Use Case TEK bir iş yapar - 50 satırı geçmesin!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}Katman 3: Interface Adapters (Presentation) {#interface-adapters}
ViewModel'ler ve Presenter'lar bu katmanda. Use Case'leri çağırır, sonuçları UI'a uygun formata dönüştürür.
swift
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 ? "Stokta" : "Tükendi",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 "Ürün stokta yok"53 case is URLError: return "İnternet bağlantısı kontrol edin"54 default: return "Beklenmeyen bir hata oluştu"55 }56 }57}58 59// Presentation model - UI'a özel60struct ProductDisplayItem: Identifiable {61 let id: UUID62 let name: String63 let formattedPrice: String64 let availability: String65 let availabilityColor: Color66 let categoryBadge: String67}Katman 4: Framework & Drivers (Infrastructure) {#framework-drivers}
En dıştaki katman. Veritabanı, network, UI framework gibi dış bağımlılıklar burada yaşar.
swift
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 // Önce cache'e bak13 if let cached: [Product] = cache.get(key: "products") {14 return cached15 }16 17 // API'den çek18 let response: ProductListResponse = try await apiClient.request(19 endpoint: .products20 )21 22 // DTO → Domain Entity dönüşümü23 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 // Cache'e kaydet36 cache.set(key: "products", value: products, expiry: .minutes(5))37 38 return products39 }40 41 func fetch(id: UUID) async throws -> Product {42 // Önce cache, sonra API...43 let response: ProductDTO = try await apiClient.request(endpoint: .product(id: id))44 return response.toDomain()45 }46}Dependency Injection: Katmanları Birleştirmek {#dependency-injection}
Katmanları birbirine bağlamak için DI Container kullanıyoruz. Bu, tüm bağımlılıkların tek bir yerde yönetilmesini sağlar.
swift
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// SwiftUI'da kullanım27struct 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 Prensipleri Pratikte {#solid-prensipleri}
Clean Architecture, SOLID prensipleriyle el ele gider. Her bir prensibi iOS kontekstinde görelim:
Prensip | Açıklama | iOS Örneği |
|---|---|---|
**Single Responsibility** | Her sınıf tek sorumluluk | ViewModel sadece UI state yönetir |
**Open/Closed** | Genişlemeye açık, değişikliğe kapalı | Protocol + extension pattern |
**Liskov Substitution** | Alt tipler üst tiplerin yerine geçebilmeli | Mock repository = gerçek repository |
**Interface Segregation** | Gereksiz dependency olmamalı | Küçük, odaklı protokoller |
**Dependency Inversion** | Soyutlamaya bağlan, somuta değil | Use Case → Protocol ← Repository |
MVC vs MVVM vs VIPER vs Clean Architecture {#karsilastirma}
Kriter | MVC | MVVM | VIPER | Clean Architecture |
|---|---|---|---|---|
**Karmaşıklık** | Düşük | Orta | Yüksek | Orta-Yüksek |
**Test edilebilirlik** | Düşük | Orta | Yüksek | Çok Yüksek |
**Ölçeklenebilirlik** | Düşük | Orta | Yüksek | Çok Yüksek |
**Öğrenme eğrisi** | Kolay | Orta | Zor | Orta |
**Boilerplate** | Az | Orta | Çok | Orta |
**Framework bağımsızlığı** | Yok | Az | Kısmen | Tam |
💡 Pro Tip: Küçük projeler (5-10 ekran) için MVVM yeterli. 20+ ekranlı, 3+ kişilik takımlarda Clean Architecture öne çıkar. VIPER'ın tüm avantajlarını daha az boilerplate ile sunar.
Testing Stratejisi {#testing}
Clean Architecture'ın en büyük avantajı: her katman bağımsız test edilebilir.
swift
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 - tamamen kontrol edilebilir25class 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 {#production-checklist}
🔑 Bu Yazıdan Çıkarımlar
- Dependency Rule: Bağımlılıklar her zaman dıştan içe akar
- Domain katmanı pure Swift: Hiçbir Apple framework'ü import etme
- Her Use Case tek iş yapmalı: 50 satırı geçmesin
- Protocol-first design: Somut sınıflara değil, protokollere bağlan
- DI Container kullan: Bağımlılıkları merkezi yönet
- DTO ↔ Domain mapping: Katmanlar arası veri dönüşümü şart
- Test-first düşün: Her katmanı bağımsız test edebilmelisin
Easter Egg
Gizli bir bilgi buldun!
Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?
Okuyucu Ödülü
Clean Architecture'ı bu derinlikte öğrenmek büyük bir başarı! Artık interview'larda mimari soruları rahatlıkla cevaplayabilir, mevcut projeleri refactor edebilir ve yeni projeleri sağlam temeller üzerine kurabilirsin. Sana özel hediye:
ALTIN İPUCU
Bu yazının en değerli bilgisi
Bu ipucu, yazının en önemli çıkarımını içeriyor.

