Büyük iOS projelerini incelerken sık gördüğümüz bir tablo var: projede bir DI kütüphanesi kurulu, AppContainer.swift dosyası birkaç yüz satır, ekip "biz dependency injection kullanıyoruz" diyor. Sonra bir view model açıyorsunuz ve init imzası tek parametre alıyor: container'ın kendisi. O sınıfın neye ihtiyaç duyduğunu öğrenmek için gövdesini baştan sona okumanız gerekiyor.
Dependency injection'ın vaadi basitti: bir tip neye ihtiyaç duyuyorsa imzasında söyler, o ihtiyacı dışarıdan biri karşılar. Container bu vaadin bir uygulama aracıdır ve yanlış yerde kullanıldığında vaadi sessizce geri alır. Bu yazı iOS tarafını anlatıyor; aynı hataların Hilt ve Koin dünyasındaki karşılıkları için Android yazısına bakabilirsiniz.
Örnek olarak hayali bir seyahat rezervasyon uygulaması kullanacağız: seyahat listesi, seyahat detayı, iptal akışı, ödeme.
Service locator, DI kostümü giyince
Aşağıdaki kod Swinject ile yazılmış, derlenen ve çalışan bir örnek. Kütüphanenin kendisinde bir kusur yok; sorun onu nereden çağırdığımızda.
import Swinject
@MainActor
enum AppAssembly {
static let container: Container = {
let c = Container()
c.register(APIClient.self) { _ in URLSessionAPIClient() }
.inObjectScope(.container)
c.register(BookingRepository.self) { r in
RemoteBookingRepository(api: r.resolve(APIClient.self)!)
}
.inObjectScope(.container)
c.register(Analytics.self) { _ in FirebaseAnalyticsAdapter() }
.inObjectScope(.container)
// ... 180 satır daha
return c
}()
}
@MainActor
final class TripDetailViewModel {
private let container: Container
private let tripID: Trip.ID
init(tripID: Trip.ID, container: Container) {
self.tripID = tripID
self.container = container
}
func cancelTrip() async throws {
let repository = container.resolve(BookingRepository.self)!
try await repository.cancel(tripID)
container.resolve(Analytics.self)!.track(.tripCancelled(tripID))
if container.resolve(FeatureFlags.self)!.isEnabled(.refundBanner) {
container.resolve(RefundNotifier.self)!.scheduleBanner(for: tripID)
}
}
}
TripDetailViewModel dört şeye bağımlı ama imzası bunların hiçbirini söylemiyor. Mark Seemann'ın service locator için yıllar önce yaptığı tespit burada birebir geçerli: sınıf bağımlılıklarını gizliyor ve hatayı derleme zamanından çalışma zamanına taşıyor. Container'ı parametre olarak almak da durumu değiştirmiyor; sınıfa "her şeye erişim" verdiniz ve neyi kullandığını saklama hakkı tanıdınız.
İkinci kostüm daha şık durur. Ekip resolve çağrılarından sıkılır ve bir property wrapper yazar:
@propertyWrapper
struct Injected<Service> {
let wrappedValue: Service
@MainActor
init() {
wrappedValue = AppAssembly.container.resolve(Service.self)!
}
}
@MainActor
final class TripDetailViewModel {
@Injected private var repository: BookingRepository
@Injected private var analytics: Analytics
@Injected private var flags: FeatureFlags
@Injected private var refunds: RefundNotifier
private let tripID: Trip.ID
init(tripID: Trip.ID) { self.tripID = tripID }
}
Bu sürüm bir öncekinden iyi görünür, çünkü bağımlılıklar en azından sınıfın tepesinde listelenmiştir. Ama init(tripID:) hâlâ yalan söylüyor. Bu sınıfı testte oluşturan kişi dört kaydın global bir container'da hazır olması gerektiğini imzadan öğrenemez; testi çalıştırıp çökmesini bekler. Testler global durumu paylaştığı için paralel çalıştırmak riskli hâle gelir. Bir bağımlılığı değiştirmek için de nesneye bir şey veremezsiniz, global kaydı ezmeniz gerekir.
DI'ın kaldırmayı vaat ettiği her şey geri gelmiş olur: gizli bağlantılar, global durum, sıraya bağlı kurulum. Tek fark, artık bunu bir kütüphane aracılığıyla yapıyor olmanızdır.
Bir tipin bağımlılık listesi
initimzasında okunmuyorsa, o projede container var ama injection yok.
Unutulan kayıt, QA'in açmadığı ekranda patlar
Swinject'te resolve bir Optional döner. README'deki örnekler bile sonucu ! ile açar, çünkü çağıran tarafın nil ile yapabileceği anlamlı bir şey yoktur. Bunun pratikteki sonucu şudur: kayıt ile kullanım arasındaki bağı derleyici bilmez.
Tanıdık senaryo: biri RefundNotifier için yeni bir bağımlılık ekler ve kaydını yazmayı unutur, ya da kaydı bir feature flag'in arkasındaki assembly'ye koyar. Uygulama açılır, ana akışlar çalışır, QA regresyon listesini geçer. İade banner'ı yalnızca iptal edilmiş ve iadesi bekleyen seyahatlerde görünür; o ekran test listesinde yoktur. Çökme raporu yayından sonra gelir ve stack trace Injected.init() satırını gösterir.
Ekiplerin buna karşı yazdığı "bütün kayıtları tek tek resolve eden test" işe yarar ama iki eksiği var: parametreli kayıtları ve koşullu assembly'leri genellikle kaçırır, ayrıca derleyicinin bedavaya yapacağı bir işi elle sürdürmeniz gerekir.
Karşılaştırma için aynı hatayı constructor injection ile yapalım:
let notifier = RefundNotifier(scheduler: notificationScheduler)
// ^ tanımlı değilse proje derlenmez
let model = TripDetailModel(
tripID: id,
repository: repository,
analytics: analytics,
refunds: notifier
)
Unutulan bağımlılık burada bir derleme hatasıdır. Ekranı kimsenin açmasına gerek yok, hata pull request'te görünür. Bu yazıdaki önerilerin tamamı bu farkın üzerine kurulu: bağımlılık grafiğini derleyicinin okuyabildiği sıradan Swift koduna çevirmek.
Tanrı container: herkesin düzenlediği dosya
Service locator'ın doğal yoldaşı tek bir assembly dosyasıdır. Belirtileri kolay tanınır.
Her feature aynı dosyayı düzenler. AppContainer.swift her pull request'te değişir, merge conflict'lerin müdavimidir ve her feature'ın iç tiplerini tanımak zorunda olduğu için app target'ı her şeyi import eder. Bir feature'ı silmek isterseniz kayıtlarını bu dosyadan ayıklamanız gerekir; hangilerinin başka yerden resolve edildiğini de derleyici söylemez.
Container parametre olarak dolaşır. Coordinator container'ı alır, view model'e verir, view model bir sonraki ekranın view model'ine verir. Container'ı elinde tutan her nesne uygulamadaki her şeye erişebilir; modül sınırlarınız kâğıt üzerinde kalır.
Her şey varsayılan olarak singleton'dır. Swinject'in varsayılan scope'u .graph'tır, yani her resolve çağrısı yeni bir nesne grafiği kurar. Ekipler bunun yol açtığı "neden iki ayrı cache var" sürprizini bir kez yaşar ve o günden sonra her kayda refleksle .inObjectScope(.container) ekler. Sonuç, başka bir isimle yazılmış bir avuç global değişkendir; bunun bedelini singleton yazısında ayrıntılı anlattık. En pahalı örneği kullanıcıya bağlı durumdur: .container scope'unda yaşayan bir BookingRepository, logout'tan sonra önceki kullanıcının seyahatlerini bellekte tutmaya devam eder.
Scope'ların ne anlama geldiğini kimse bilmez. Projede app, session, flow, screen diye dört scope tanımlıdır ve hangisinin ne zaman sıfırlandığını öğrenmek için kütüphanenin kaynak kodunu okumak gerekir. Yeni gelen geliştirici en güvenli görüneni seçer, o da genellikle en uzun yaşayandır.
Scope aslında basit bir sorudur: bu nesnenin sahibi kim ve sahibi ne zaman ölüyor? Bizim kullandığımız dört scope'un tamamı sıradan nesne sahipliğiyle ifade edilebilir:
| Scope | Sahibi | Ne zaman ölür | Tipik içerik |
|---|---|---|---|
| App | AppGraph (app delegate ya da App struct'ı tutar) |
Süreç sonlanınca | HTTP client, analytics, konfigürasyon |
| Session | SessionGraph (AppGraph tutar) |
Logout'ta | Kullanıcı, yetkili API client, kullanıcıya ait cache'ler |
| Flow | Coordinator ya da akışın kök view'ı | Akış kapanınca | Çok adımlı rezervasyon taslağı |
| Screen | View controller ya da SwiftUI view'ın @State'i |
Ekran kapanınca | View model |
Bu tabloda kütüphaneye ait tek bir kavram yok. Bir nesnenin ömrünü, onu tutan let belirliyor.
Her şeye protocol: tek implementasyonlu arayüzler
Container kültürünün yan ürünü şu çifttir:
protocol TripPriceFormatterProtocol {
func format(_ price: Money) -> String
}
final class TripPriceFormatterImpl: TripPriceFormatterProtocol {
func format(_ price: Money) -> String { /* ... */ }
}
Protocol yalnızca "DI için" vardır: container bir arayüz tipine kayıt ister diye düşünülmüştür. İkinci bir implementasyon yoktur, testte de gerçek sınıf kullanılır, çünkü saf bir fonksiyonu mock'lamanın anlamı yoktur. Bedeli ise gerçektir: her imza değişikliği iki yerde yapılır, "jump to definition" sizi protocol'e götürür, ve okuyan kişi her seferinde "bunun başka hâli var mı" diye arar.
Bir protocol şu üç durumdan birinde yerini hak eder:
- Gerçek implementasyon süreç dışına çıkıyor ya da deterministik değil: ağ, disk, saat, rastgelelik, bildirim izni. Testte ikinci bir implementasyon zaten yazacaksınız.
- Tip bir modül sınırında duruyor ve tüketen modülün implementasyonu görmesini istemiyorsunuz. Bu, aşağıda anlattığımız interface module durumudur.
- Bağımlılığın yönünü çevirmeniz gerekiyor: alt katmandaki bir tip, üst katmandan bir davranışa ihtiyaç duyuyor.
Bunların dışında somut tipi enjekte edin. TripPriceFormatter bir struct olarak init'e girer ve gün gelip ikinci bir implementasyon gerekirse protocol'ü o gün çıkarırsınız; Xcode'un refactoring araçları bunu dakikalar içinde yapar. Constructor injection protocol gerektirmez, yalnızca nesneyi kimin oluşturduğunu değiştirir.
SwiftUI: environment da örtük bir global olabilir
SwiftUI, service locator'ı framework'ün içinde hazır sunar. @EnvironmentObject ile bir view, ağaçta yukarıda birinin koyduğunu umduğu bir nesneyi ister:
struct TripDetailScreen: View {
@EnvironmentObject private var bookings: BookingStore
@EnvironmentObject private var analytics: AnalyticsStore
let tripID: Trip.ID
var body: some View { /* ... */ }
}
TripDetailScreen(tripID:) çağrısı derlenir. Nesne ortamda yoksa uygulama çalışma zamanında, view o property'ye ilk dokunduğunda durur. Apple'ın dokümantasyonu bunu "bir üst view'da ilgili modifier'ı çağırdığınızdan emin olun" diye ifade eder. Hatanın tipik çıkış noktaları bellidir: UIKit navigasyonunun içinden oluşturulan bir UIHostingController kökü, environment'ı hazırlamayı unutan bir preview, ya da view'ı başka bir ağaçtan yeniden kullanan ikinci bir ekran. Yani unutulan container kaydının aynısı, bu kez view hiyerarşisinde.
Observation döneminde (iOS 17 ve sonrası) API değişti ama doğası aynı kaldı. @Environment(BookingStore.self) için dokümantasyon açık konuşuyor: nesne ortamda yoksa SwiftUI exception fırlatır. Tipi BookingStore? olarak bildirirseniz nil alırsınız, bu da çökmeyi sessiz bir boş ekrana çevirir. @Entry ile tanımlanan key'lerde ise bir varsayılan değer vermek zorundasınız; çökme olmaz, ama üretimde fark edilmeden varsayılan NoopAnalytics ile çalışan bir ekran da hoş bir sürpriz sayılmaz.
Bizim ayrımımız şöyle: environment, view ağacının gerçekten tamamını ilgilendiren ve UI'a ait olan değerler içindir. Tema, locale, oturumdaki kullanıcının görünen adı, bir dismiss aksiyonu. Repository'ler, API client'lar ve use case'ler view'ın işi değildir; onlar view model'in init'ine girer, view model de view'a parametre olarak verilir.
import Observation
import SwiftUI
@MainActor
@Observable
final class TripDetailModel {
private(set) var trip: Trip?
private(set) var isCancelling = false
@ObservationIgnored private let tripID: Trip.ID
@ObservationIgnored private let repository: any BookingRepository
@ObservationIgnored private let analytics: any Analytics
init(tripID: Trip.ID, repository: any BookingRepository, analytics: any Analytics) {
self.tripID = tripID
self.repository = repository
self.analytics = analytics
}
func load() async throws {
trip = try await repository.trip(id: tripID)
}
func cancel() async throws {
isCancelling = true
defer { isCancelling = false }
try await repository.cancel(tripID)
analytics.track(.tripCancelled(tripID))
}
}
struct TripDetailScreen: View {
@State private var model: TripDetailModel
init(model: TripDetailModel) {
_model = State(initialValue: model)
}
var body: some View {
Group {
if let trip = model.trip {
TripSummaryView(trip: trip, isCancelling: model.isCancelling)
} else {
ProgressView()
}
}
.task { try? await model.load() }
}
}
Bir uyarı: State, başlangıç değerini view her oluşturulduğunda yeniden hesaplar ve yalnızca ilkini saklar. Apple bu yüzden başlangıç değerinde yan etkiden ve pahalı işten kaçınmayı söylüyor. View model'in init'i yalnızca atama yapmalı; ağ çağrısı .task içinde başlamalı. Yukarıdaki örnek bu kurala uyuyor.
Önerdiğimiz kurulum: composition root, fabrikalar, açık scope'lar
Kural tek cümle: nesneler bağımlılıklarını init'ten alır ve grafiği kuran kod tek bir yerde, uygulamanın giriş noktasında durur. Seemann buna composition root diyor. Geri kalan her şey bu kuralın ayrıntısı.
App ve session scope'u
@MainActor
final class AppGraph {
let configuration: AppConfiguration
let analytics: any Analytics
let authenticator: Authenticator
private let httpClient: any APIClient
private(set) var session: SessionGraph?
init(configuration: AppConfiguration) {
self.configuration = configuration
let httpClient = URLSessionAPIClient(baseURL: configuration.apiBaseURL)
self.httpClient = httpClient
self.analytics = FirebaseAnalyticsAdapter()
self.authenticator = Authenticator(api: httpClient)
}
@discardableResult
func startSession(user: User, token: AccessToken) -> SessionGraph {
let graph = SessionGraph(
user: user,
api: AuthorizedAPIClient(wrapping: httpClient, token: token),
analytics: analytics
)
session = graph
return graph
}
func endSession() {
session = nil // kullanıcıya ait her şey burada serbest kalır
}
}
@MainActor
final class SessionGraph {
let user: User
private let api: any APIClient
private let analytics: any Analytics
// Session boyunca tek örnek: sahibi bu nesne.
private lazy var bookings: any BookingRepository = RemoteBookingRepository(api: api)
init(user: User, api: any APIClient, analytics: any Analytics) {
self.user = user
self.api = api
self.analytics = analytics
}
func makeBookingFeature() -> BookingFeature {
BookingFeature(dependencies: .init(
repository: bookings,
analytics: analytics
))
}
}
"Session scope" burada bir kütüphane ayarı olmaktan çıkıp bir nesneye dönüşüyor. Logout'ta endSession() çağrılır, SessionGraph ölür, onun tuttuğu repository ve cache'ler de onunla gider. Önceki kullanıcının verisinin bellekte kalması için birinin SessionGraph'a güçlü referans tutması gerekir ve bunu bir testle yakalayabilirsiniz (aşağıda).
Bu sınıflar bir container'a benziyor ve bir anlamda öyleler. Fark şu: yalnızca composition root onları tanır. Hiçbir view model, hiçbir feature AppGraph tipini import etmez.
Sonradan oluşan nesneler için küçük fabrikalar
Seyahat detayının view model'i, kullanıcı listeden bir satıra dokunduğunda ve tripID belli olduğunda oluşur. Container'ı parametre olarak geçirmenin gerekçesi genellikle budur: "nesneyi şimdi oluşturamıyorum". Çözüm, yalnızca o nesneyi üretebilen dar bir tiptir:
@MainActor
struct TripDetailFactory {
let repository: any BookingRepository
let analytics: any Analytics
func make(tripID: Trip.ID) -> TripDetailModel {
TripDetailModel(tripID: tripID, repository: repository, analytics: analytics)
}
}
struct TripListScreen: View {
let model: TripListModel
let detailFactory: TripDetailFactory
var body: some View {
List(model.trips) { trip in
NavigationLink(trip.title, value: trip.id)
}
.navigationDestination(for: Trip.ID.self) { id in
TripDetailScreen(model: detailFactory.make(tripID: id))
}
}
}
TripListScreen artık tek bir şey üretebilir: seyahat detayı. Ödeme servisine uzanamaz, çünkü elinde ona giden bir yol yok. Fabrikayı bir closure olarak da geçirebilirsiniz ((Trip.ID) -> TripDetailModel); biz üç ve üzeri parametrede ya da birden fazla üretim metodunda isimli bir tipi tercih ediyoruz, çünkü çağrı yerinde okunması kolay.
Feature başına bir composition root
App seviyesindeki grafik her feature'ın iç tiplerini bilirse tanrı container'ı elle yeniden yazmış olursunuz. Her feature kendi iç grafiğini kendisi kurar ve dışarıya iki şey açar: neye ihtiyaç duyduğu ve nereden girileceği.
public struct BookingDependencies {
public var repository: any BookingRepository
public var analytics: any Analytics
public init(repository: any BookingRepository, analytics: any Analytics) {
self.repository = repository
self.analytics = analytics
}
}
@MainActor
public final class BookingFeature {
private let dependencies: BookingDependencies
public init(dependencies: BookingDependencies) {
self.dependencies = dependencies
}
public func makeTripList() -> some View {
TripListScreen(
model: TripListModel(repository: dependencies.repository),
detailFactory: TripDetailFactory(
repository: dependencies.repository,
analytics: dependencies.analytics
)
)
}
}
TripListModel, TripDetailFactory ve ekranlar internal kalır. App target'ı yalnızca BookingDependencies ve BookingFeature'ı görür. Feature'a yeni bir dış bağımlılık eklendiğinde BookingDependencies.init değişir ve app derlenmez; unutulan kayıt senaryosunun bu kurulumdaki karşılığı bir derleme hatasıdır.
Kütüphane nerede işe yarar
Yukarıdaki kurulum kütüphane gerektirmiyor ve orta ölçekli bir uygulamada elle yazılan kablolama kodu sanıldığı kadar büyümez. Yine de dört yaygın seçeneğin bugünkü durumunu ve nereye oturduklarını söyleyelim.
Swinject (2.10.0, Eylül 2025) klasik çalışma zamanı container'ıdır: register ile kaydedersiniz, resolve bir Optional döner, scope'lar .transient, .graph (varsayılan), .container ve .weak'tir. Derleme zamanı garantisi sunmaz. Mevcut bir projede duruyorsa onu yalnızca composition root'un içinde tutun; feature kodu Swinject'i import etmesin.
Factory (3.x, modül adı artık FactoryKit) kayıtları Container extension'ında computed property olarak tanımlatır, dolayısıyla tanımı olmayan bir factory'ye key path yazamazsınız ve kod derlenmez. Varsayılan scope unique'tir; .singleton, .cached, .shared ve kendi tanımladığınız scope'lar eklenir. Kayıt unutma sorununu çözer. Çözmediği şey, @Injected(\.myService) ve Container.shared kullanımının bağımlılık listesini yine init imzasının dışında bırakmasıdır. Factory'yi seçerseniz factory'leri composition root'ta çağırıp sonucu init'e vermeyi öneririz; README'de bu kullanım da gösteriliyor.
swift-dependencies (1.17.x) @Dependency(\.key) ile okunan, task local üzerine kurulu değerler sunar; override'lar withDependencies bloğuyla sınırlıdır ve bir testte override edilmemiş live bağımlılığa erişmek test hatası üretir. Bu, global container'a göre ciddi biçimde daha güvenli bir ortam değeridir. Biz saat, tarih, UUID gibi gerçekten her yerde gereken sistem benzeri değerler için makul buluyoruz. Feature'lar arası kablolama için kullanmıyoruz, çünkü bağımlılık yine imzada görünmüyor.
Needle bileşen hiyerarşisini kaynak koddan okuyup (SourceKit üzerinden) kablolama kodunu üretir ve eksik bağımlılığı derleme hatasına çevirir. Karşılığında build'e bir generator adımı eklersiniz. Depoda commit'ler sürüyor, ancak son etiketli sürüm (v0.25.1) Eylül 2024 tarihli; benimsemeden önce kendi Xcode ve Swift sürümünüzle generator'ı denemenizi öneririz.
Kararımız: elle kablolama varsayılan. Kütüphaneye, composition root kodu gerçekten ağırlaştığında ve yalnızca root'un içinde kalması şartıyla geçeriz.
Modüller arasında DI: kablolama kimin işi
Modüllere bölünmüş bir projede container'ın ortak bir Core modülünde durması, her feature'ın o modülü import edip istediğini resolve etmesi demektir. Modül grafiği kâğıt üzerinde temiz görünür; gerçek bağımlılıklar string'ler ve tip adları üzerinden çalışma zamanında kurulur.
Bizim kurduğumuz yapı şu:
App (composition root: AppGraph, SessionGraph)
├─ BookingFeature ──► BookingInterface, PaymentsInterface
├─ PaymentsFeature ──► PaymentsInterface
├─ BookingInterface (BookingRepository protocol'ü, Trip modeli)
└─ PaymentsInterface (PaymentFlowStarting protocol'ü, PaymentResult)
BookingFeature ödeme akışını başlatmak ister ama PaymentsFeature'ı import etmez. PaymentsInterface içindeki PaymentFlowStarting protocol'üne bağımlıdır ve bunu BookingDependencies üzerinden ister. İki feature'ı da tanıyan tek modül App'tir ve bağlantıyı o kurar: BookingDependencies(payments: paymentsFeature, ...). Feature'lar ne birbirini ne de grafiği kuran tipi bilir.
Bu yapının iki yan faydası var. Feature modülü, interface modülleri dışında hiçbir şeye bağlı olmadığı için tek başına derlenir ve kendi demo uygulamasında sahte bağımlılıklarla çalışır. İkincisi, "bu feature neye bağımlı" sorusunun cevabı tek bir struct'ın alan listesidir.
Test: iyi kablolamanın bedavaya getirdikleri
Constructor injection'da test kurulumu, nesneyi oluşturmaktan ibarettir. Global bir container'ı hazırlamak, test sonunda sıfırlamak ya da testleri sıraya sokmak gerekmez.
import Testing
@testable import BookingFeature
@MainActor
struct TripDetailModelTests {
@Test
func cancellingTracksTheEvent() async throws {
let repository = BookingRepositoryStub(trips: [.sample])
let analytics = AnalyticsSpy()
let model = TripDetailModel(
tripID: Trip.sample.id,
repository: repository,
analytics: analytics
)
try await model.cancel()
#expect(repository.cancelledIDs == [Trip.sample.id])
#expect(analytics.events == [.tripCancelled(Trip.sample.id)])
}
}
@MainActor
struct SessionLifecycleTests {
@Test
func logoutReleasesTheSessionGraph() {
let app = AppGraph(configuration: .testing)
weak var session = app.startSession(user: .sample, token: .sample)
app.endSession()
#expect(session == nil)
}
}
İkinci test, scope'un gerçekten kapandığını doğrular. Bir gün biri SessionGraph'ı uzun ömürlü bir nesnenin closure'ında yakalarsa bu test kırılır ve "logout'tan sonra eski kullanıcının verisi görünüyor" hatası üretime çıkmadan yakalanır. Her test kendi nesnelerini kurduğu için Swift Testing'in paralel çalıştırması da sorun çıkarmaz.
Composition root'un kendisi için ayrı bir "bütün kayıtlar çözülüyor mu" testi yazmanıza gerek kalmaz. O testin işini derleyici yapıyor.
Resolve-her-yerde bir projeden adım adım geçiş
Mevcut bir projeyi bir sprint'te çevirmeye çalışmayın. Aşağıdaki sıra, her adımda yayınlanabilir bir uygulama bırakır.
1. Ölçün. Composition klasörü dışındaki service locator kullanımını sayın ve sayıyı kaydedin:
grep -rnE "\.resolve\(|@Injected|Container\.shared" Sources --include="*.swift" \
| grep -v "/Composition/" | wc -l
2. Dondurun. Bu sayının artmasını CI'da engelleyin. Yeni kod constructor injection ile yazılır; eski kod sırası gelince taşınır. Kuralı kalıcı bir lint ya da build kontrolüne çevirmenin yollarını mimari kurallarını zorlama yazısında anlattık.
3. Yapraklardan başlayın. Bir view model seçin, resolve ettiği her şeyi init parametresi yapın. resolve çağrıları silinmez, bir kat yukarı taşınır: view model'i oluşturan coordinator ya da view artık resolve edip init'e verir. View model o andan itibaren container'sız test edilebilir.
4. Container parametrelerini fabrikalarla değiştirin. Container'ı yalnızca "sonra bir şey oluşturmak" için tutan her tipe dar bir fabrika verin. Bu adımın sonunda container'ı tanıyan tipler coordinator'lar ve assembly'lerle sınırlı kalır.
5. Session scope'unu ayırın. Kullanıcıya bağlı kayıtları SessionGraph'a taşıyın, logout'ta bırakın ve yukarıdaki weak testini yazın. Geçişin en çok hata yakalayan adımı genellikle budur.
6. Feature feature kayıtları koda çevirin. Her feature için Dependencies struct'ı ve bir giriş tipi yazın; o feature'ın kayıtlarını assembly dosyasından silin. Coordinator'lardaki resolve çağrıları composition root'taki düz Swift koduna dönüşür.
7. Sayı sıfıra inince karar verin. Container artık yalnızca root'un içindedir. Onu AppGraph ile tamamen değiştirebilir ya da root'un bir uygulama ayrıntısı olarak bırakabilirsiniz; ikisi de kabul edilebilir, çünkü uygulamanın geri kalanı farkı göremez.
İşe yaradığını şuradan anlarsınız: birinci adımdaki sayı sıfırdır, bir feature modülü app target'ı olmadan derlenip test edilir ve eksik bir bağımlılık çalışma zamanı çökmesi yerine derleme hatası olarak görünür.
Bu yaklaşımın sınırı
On ekranlı bir uygulamada feature başına composition root ve interface modülleri fazladır. Kırk satırlık tek bir AppGraph ve constructor injection yeterlidir; modül sınırlarını ekip ve kod büyüdüğünde eklersiniz.
Öbür uçta, yüzlerce modüllü bir projede elle yazılan kablolama kodu hatırı sayılır bir hacme ulaşır ve Dependencies struct'larını zincirleme güncellemek yorucu olur. Kod üretimi (Needle gibi) ya da key path tabanlı bir container (Factory gibi) o noktada meşru bir tercih olur. Şart değişmez: kütüphane composition root'ta kalır, feature kodu init ile yaşamaya devam eder.
SwiftUI'da her değeri on kat view'dan parametre olarak geçirmek de bir yanlış uygulamadır. View ağacına ait, UI ile ilgili ve varsayılanı anlamlı olan değerler için environment doğru araçtır.
Kontrol listesi
- Composition klasörü dışında
resolve(,@InjectedveContainer.sharedsayısı ölçüldü; artışı CI'da engelleniyor. - Hiçbir view model, coordinator dışı tip ya da feature modülü container tipini import etmiyor ve parametre olarak almıyor.
- Her view model'in bağımlılıkları
initimzasında okunuyor. - Sonradan oluşan nesneler için dar fabrika tipleri (ya da closure'lar) var; container geçirilmiyor.
- App, session, flow ve screen scope'larının her birinin sahibi bir nesne ve ömrü o nesneyle bitiyor.
- Logout'ta session grafiği serbest kalıyor ve bunu doğrulayan bir
weakreferans testi var. - Tek implementasyonlu protocol'ler gözden geçirildi; IO, modül sınırı ya da bağımlılık yönü gerekçesi olmayanlar somut tipe çevrildi.
- SwiftUI environment'ında yalnızca UI'a ait değerler var; repository ve servisler view model'in
init'inden giriyor. - Her feature dışarıya bir
Dependenciesstruct'ı ve bir giriş tipi açıyor; feature'lar birbirini interface modülleri üzerinden tanıyor. - Bir DI kütüphanesi kullanılıyorsa yalnızca composition root'ta import ediliyor.
Bu konuda desteğe mi ihtiyacınız var?
Uygulamanızda bu kontrollerin doğru kurulup kurulmadığını birlikte inceleyelim. Güvenlik denetimi ya da danışmanlıkla buradayız.
Kaynaklar
- Martin Fowler · Inversion of Control Containers and the Dependency Injection pattern
- Mark Seemann · Service Locator is an Anti-Pattern
- Mark Seemann · Composition Root
- Apple · Environment
- Apple · EnvironmentObject
- Apple · State
- Swinject · Object Scopes
- Factory · README
- Point-Free · swift-dependencies
- Uber · Needle code generator
Yorumlar ve sorular
Katılmadığınız bir nokta ya da projenizde takıldığınız bir yer mi var? Yazın, konuşalım.