Dependency Injection'ın yanlış kullanımı: iOS'ta DI container anti-pattern'leri

Projeye bir DI container eklemek, bağımlılıkları enjekte ettiğiniz anlamına gelmiyor. Service locator'a dönüşen resolve çağrılarını, QA'in açmadığı ekranda patlayan kayıtları, tanrı container'ı ve SwiftUI environment tuzaklarını; yerine koyduğumuz composition root'u ve geçiş adımlarını anlatıyoruz.

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 init imzası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(, @Injected ve Container.shared sayı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ı init imzası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 weak referans 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 Dependencies struct'ı 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.

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.