iOS'ta Ölçeklenebilir Modüler Mimari
Tek hedefli bir iOS uygulamasını bağımsız modüllere bölmenin pratik kuralları: SPM yapısı, bağımlılık yönü ve ölçek alındığında karşılaşılan tuzaklar.
1. Giriş: Neden Modülerlik?
Tek bir Xcode hedefi (target) içinde büyüyen bir uygulama, belirli bir boyuttan sonra geliştiriciye karşı çalışmaya başlar. Derleme süreleri uzar, küçük bir değişiklik tüm projenin yeniden derlenmesine yol açar, ekipler aynı dosyalarda çakışır ve test edilebilirlik düşer. Modüler mimari bu sorunları, uygulamayı bağımsız olarak derlenebilen, test edilebilen ve geliştirilebilen parçalara bölerek çözer.
Modülerliğin somut faydaları şunlardır:
- Derleme süresi: Yalnızca değişen modül ve ona bağımlı olanlar yeniden derlenir. Incremental build kazanımı büyük projelerde dakikalar mertebesindedir.
- Ekip ölçeklenmesi: Farklı ekipler farklı modüllerde paralel çalışır, merge çakışmaları azalır.
- Test edilebilirlik: Bir modülü izole şekilde test etmek, tüm uygulamayı ayağa kaldırmaktan çok daha hızlıdır.
- Yeniden kullanılabilirlik: Bir tasarım sistemi veya ağ katmanı modülü birden fazla uygulamada paylaşılabilir.
- Sınır netliği: Modül sınırları, mimari kararların kod tarafından zorlanmasını sağlar.
2. Temel İlke: Bağımlılık Yönü
Sağlıklı bir modüler mimarinin tek en önemli kuralı, bağımlılıkların tek yönde akmasıdır. Üst seviye modüller (feature’lar) alt seviye modüllere (core, domain) bağımlı olabilir, ancak tersi asla olmamalıdır. Domain katmanı, kendisini kimin kullandığını bilmemelidir.
App
├── Feature: Onboarding
├── Feature: Gallery
│ ├── Domain (iş kuralları)
│ └── DesignSystem
└── Core
├── Networking
├── Persistence
└── Logging
Bu yapıda oklar yukarıdan aşağıya akar. Bir Core modülünün bir Feature modülünü import etmesi, mimari bir kod kokusudur ve döngüsel bağımlılığa kapı açar.
3. Modül Türleri
Pratikte modülleri üç ana kategoriye ayırmak işe yarar:
- Feature modülleri: Kullanıcının gördüğü ekranlar ve akışlar. Örneğin
GalleryFeature,SettingsFeature. Birbirlerine doğrudan bağımlı olmamaları tercih edilir. - Core / altyapı modülleri: Ağ, kalıcılık, loglama, analitik gibi teknik yetenekler. Feature’lar bunları kullanır.
- Paylaşılan (shared) modüller: Tasarım sistemi, ortak modeller, yardımcı uzantılar. Hem feature hem core tarafından kullanılabilir.
Bir tuzaktan kaçınmak gerekir: her şeyi içine alan bir Common veya Utils modülü zamanla devasa bir bağımlılık çöplüğüne döner. Bunun yerine küçük ve amaca yönelik modüller tercih edilmelidir.
4. Swift Package Manager ile Modülerizasyon
Modern iOS projelerinde modülleri ayırmanın en pratik yolu Swift Package Manager’dır (SPM). Her modül bir local package olarak tanımlanır. Aşağıda tek bir paket içinde birden çok ürün (product) ve hedef (target) tanımlayan bir Package.swift örneği vardır.
// Package.swift
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Modules",
platforms: [.iOS(.v16)],
products: [
.library(name: "GalleryFeature", targets: ["GalleryFeature"]),
.library(name: "Networking", targets: ["Networking"]),
.library(name: "DesignSystem", targets: ["DesignSystem"])
],
targets: [
.target(
name: "GalleryFeature",
dependencies: ["Networking", "DesignSystem"]
),
.target(
name: "Networking",
dependencies: []
),
.target(
name: "DesignSystem",
dependencies: []
),
.testTarget(
name: "GalleryFeatureTests",
dependencies: ["GalleryFeature"]
)
]
)
Burada GalleryFeature, Networking ve DesignSystem modüllerine bağımlıdır. Ters yönde bir bağımlılık tanımlamaya çalışırsanız SPM döngüsel bağımlılık hatası verir. Bu, mimari kuralın derleyici tarafından zorlanması demektir.
5. Katmanlı Mimari: Presentation, Domain, Data
Modüler yapı, fiziksel bir ayrımdır. Bunun içinde mantıksal bir katmanlı yapı da kurmak gerekir. Yaygın bir yaklaşım üç katmandır:
- Domain: İş kuralları, varlıklar (entity) ve use case’ler. Hiçbir framework’e bağımlı değildir, saf Swift’tir.
- Data: Repository implementasyonları, ağ ve veritabanı erişimi. Domain’deki protokolleri uygular.
- Presentation: ViewModel’ler ve View’lar. Kullanıcı etkileşimini yönetir.
Önce domain katmanını protokollerle tanımlarız:
// Domain katmanı: saf Swift, framework bağımsız
public struct Photo: Identifiable, Equatable {
public let id: String
public let isBlurry: Bool
public let sizeInBytes: Int
public init(id: String, isBlurry: Bool, sizeInBytes: Int) {
self.id = id
self.isBlurry = isBlurry
self.sizeInBytes = sizeInBytes
}
}
public protocol PhotoRepository {
func fetchPhotos() async throws -> [Photo]
func deletePhotos(ids: [String]) async throws
}
Use case, iş kuralını kapsar ve repository soyutlamasına bağımlıdır:
public struct FindBlurryPhotosUseCase {
private let repository: PhotoRepository
public init(repository: PhotoRepository) {
self.repository = repository
}
public func execute() async throws -> [Photo] {
let all = try await repository.fetchPhotos()
return all.filter { $0.isBlurry }
}
}
Dikkat edilirse use case, verinin nereden geldiğini (ağ mı, yerel veritabanı mı) bilmez. Bu, data katmanının sorumluluğudur ve test sırasında sahte (mock) bir repository ile kolayca değiştirilebilir.
6. Bağımlılık Enjeksiyonu (Dependency Injection)
Modüller arası gevşek bağlamayı korumak için somut tipler değil, protokoller enjekte edilmelidir. Aşağıda basit, harici kütüphane gerektirmeyen bir DI container örneği vardır:
public final class DependencyContainer {
private var factories: [String: () -> Any] = [:]
public init() {}
public func register<T>(_ type: T.Type, factory: @escaping () -> T) {
factories[String(describing: type)] = factory
}
public func resolve<T>(_ type: T.Type) -> T {
let key = String(describing: type)
guard let factory = factories[key], let instance = factory() as? T else {
fatalError("\(key) için kayıt bulunamadı")
}
return instance
}
}
Kullanımı:
let container = DependencyContainer()
container.register(PhotoRepository.self) {
LivePhotoRepository(client: NetworkClient())
}
let repository = container.resolve(PhotoRepository.self)
let useCase = FindBlurryPhotosUseCase(repository: repository)
Bu yaklaşım presentation katmanının data implementasyonunu hiç görmemesini sağlar. ViewModel yalnızca use case’i bilir.
7. Modüller Arası İletişim
İki feature modülünün birbirini doğrudan import etmesi, sıkı bağlamaya ve döngüsel bağımlılık riskine yol açar. Bunun yerine yönlendirme (navigation) ve iletişim soyut bir arayüz üzerinden yapılmalıdır. Yaygın çözüm, paylaşılan bir modülde tanımlanmış bir router veya coordinator protokolüdür.
// Paylaşılan modülde tanımlı
public protocol GalleryRouting {
func openSettings()
func openPhotoDetail(id: String)
}
GalleryFeature yalnızca bu protokole bağımlıdır. Asıl yönlendirme mantığı App katmanında, tüm modülleri gören noktada implemente edilir:
// App katmanında, tüm feature'ları gören yer
final class AppRouter: GalleryRouting {
func openSettings() {
// SettingsFeature'ı sunar
}
func openPhotoDetail(id: String) {
// Detay ekranını gösterir
}
}
Bu sayede GalleryFeature ile SettingsFeature arasında doğrudan bir derleme bağımlılığı oluşmaz.
8. Örnek Feature Modül Yapısı
Bir feature modülü içinde tutarlı bir klasör yapısı sürdürmek, ekibin yön bulmasını kolaylaştırır:
GalleryFeature/
├── Domain/
│ ├── Photo.swift
│ ├── PhotoRepository.swift
│ └── FindBlurryPhotosUseCase.swift
├── Data/
│ └── LivePhotoRepository.swift
├── Presentation/
│ ├── GalleryViewModel.swift
│ └── GalleryView.swift
└── GalleryRouting.swift
ViewModel, use case’i ve router’ı enjeksiyonla alır:
@MainActor
public final class GalleryViewModel: ObservableObject {
@Published public private(set) var blurryPhotos: [Photo] = []
@Published public private(set) var isLoading = false
private let useCase: FindBlurryPhotosUseCase
private let router: GalleryRouting
public init(useCase: FindBlurryPhotosUseCase, router: GalleryRouting) {
self.useCase = useCase
self.router = router
}
public func load() async {
isLoading = true
defer { isLoading = false }
do {
blurryPhotos = try await useCase.execute()
} catch {
blurryPhotos = []
}
}
public func openDetail(_ photo: Photo) {
router.openPhotoDetail(id: photo.id)
}
}
9. Build ve CI Üzerindeki Etkiler
Modülerizasyon yalnızca kod düzeni değil, aynı zamanda bir performans stratejisidir. Pratik kazanımlar:
- Önbelleğe alınmış (cached) derleme: Değişmeyen modüller yeniden derlenmez. CI üzerinde Swift Package cache ile süre ciddi şekilde kısalır.
- Paralel test: Modül başına test hedefi, paralel çalıştırmaya uygundur.
- Sample app yaklaşımı: Her feature modülü için minik bir örnek uygulama (demo app) oluşturmak, tüm uygulamayı ayağa kaldırmadan o feature’ı izole çalıştırmayı sağlar. Bu, geliştirme döngüsünü hızlandırır.
Bir uyarı: aşırı modülerizasyon da zararlıdır. Onlarca minik modül, paket çözümleme (resolution) ve bağlama (linking) maliyeti getirir. Modül sınırlarını ekip yapısına ve değişim sıklığına göre çizmek en sağlıklısıdır.
10. Sonuç
Ölçeklenebilir modüler mimari, tek bir teknikten ibaret değildir. Net bağımlılık yönü, katmanlı sorumluluk ayrımı, protokol tabanlı iletişim ve bağımlılık enjeksiyonunun bir araya gelmesidir. Doğru uygulandığında derleme süreleri düşer, test edilebilirlik artar ve birden fazla geliştirici birbirinin ayağına basmadan çalışabilir. Anahtar nokta, modül sınırlarını mimari niyetinizi yansıtacak şekilde, ne fazla ince ne fazla kalın çizmektir.
← Yazılara dön