Singleton'ları tamamen yok etmek gerçekten doğru mu?

Büyük iOS ve Android projelerinde can yakan şey gizli erişim ve global mutable state; bir nesneden tek tane olması kimseyi incitmiyor. Hangi singleton kalır, hangisi oturuma bağlanır, hangisi silinir: Swift 6 ve Hilt ile adım adım anlatıyoruz.

Code review'da en ucuz yorum "singleton kötüdür, kaldıralım" yorumudur. Yazması beş saniye sürer, itiraz eden çıkmaz, yazan kişi de mimariye katkı yapmış hisseder. Altı ay sonra projede Logger her ViewModel'e constructor'dan giriyor, OkHttpClient üç ayrı yerde yaratılıyor, logout sonrası önceki kullanıcının sepetinin görünmesi bug'ı ise hâlâ açık duruyor. Çünkü kimse o bug'ın sebebine dokunmadı.

Bizim pozisyonumuz net: büyük kod tabanlarında can yakan şey gizli erişim ve global mutable state. Bir nesneden süreç boyunca tek tane olması kendi başına kimseyi incitmiyor. Bu yazıda neyin gerçekten bozulduğunu, neyin tek instance olarak kalması gerektiğini ve mevcut bir projeyi büyük bir yeniden yazım olmadan nasıl oraya taşıdığımızı anlatıyoruz. Örnek olarak hayali bir seyahat rezervasyon uygulaması kullanacağız.

"Tek tane olsun" ile "her yerden ulaşılsın" iki ayrı karar

Klasik singleton kalıbı iki kararı tek satıra sıkıştırır:

final class SessionManager {
    static let shared = SessionManager()   // 1) tek instance  2) herkes ulaşabilir
    private init() {}
}

Birinci karar yaşam döngüsüyle ilgilidir: bu nesneden süreçte kaç tane olacak ve ne kadar yaşayacak. İkinci karar erişimle ilgilidir: bu nesneyi kim, nereden görebilecek. Bağlantı havuzu tutan bir HTTP istemcisinden tek tane olması gayet makul bir yaşam döngüsü kararıdır. Aynı istemciye Formatters klasöründeki bir extension'ın içinden ulaşılabilmesi ise bambaşka, çoğu zaman da kimsenin bilinçli olarak vermediği bir erişim kararıdır.

"Singleton'ları silelim" diyen ekiplerin çoğu ikinci karardan şikâyetçidir ama birincisini cezalandırır. Sonuç, tek olması gereken şeylerin çoğalması ve gizli erişimin başka bir isimle (service locator, global Container.resolve()) geri gelmesidir. Bu ikinci tabloyu iOS'ta DI anti-pattern'leri yazısında ayrıca ele alıyoruz.

Instance sayısı bir yaşam döngüsü kararıdır, erişim yolu bir bağımlılık kararıdır. Birini düzeltmek için diğerini bozmayın.

Gerçekten bozulan beş şey

Önce gerçekçi bir "önce" hâli. Büyük projelerde sık gördüğümüz tablo aşağı yukarı böyledir:

final class SessionManager {
    static let shared = SessionManager()
    private init() {}

    var currentUser: User?
    var authToken: String?
    var recentSearches: [SearchQuery] = []
    var draftBooking: BookingDraft?
}

final class CheckoutViewModel {
    func confirm() async throws {
        guard let user = SessionManager.shared.currentUser,
              let draft = SessionManager.shared.draftBooking else { return }

        let price: Money
        if FeatureFlags.shared.isEnabled(.dynamicPricing) {
            price = try await APIClient.shared.quote(for: draft)
        } else {
            price = draft.listPrice
        }

        try await APIClient.shared.book(draft, price: price, userID: user.id)
        Analytics.shared.track(.bookingConfirmed(draft.id))
        SessionManager.shared.draftBooking = nil
    }
}

Bu kod çalışır, yıllarca da çalışır. Sorunlar ekip ve kod büyüdükçe ortaya çıkar.

Gizli bağımlılıklar

CheckoutViewModel() imzası "hiçbir şeye ihtiyacım yok" diyor. Gövdesi ise dört ayrı global nesneye bağlı. Bu sınıfı başka bir module'e taşımak istediğinizde derleyici size dört bağımlılığı teker teker, hata mesajlarıyla öğretir. Daha kötüsü: APIClient.shared çağrısı bir ViewModel'de olduğunda görünürdür, bir Date extension'ının ya da bir UITableViewCell alt sınıfının içinde olduğunda kimse görmez. Bağımlılık grafiğiniz kâğıt üzerinde temiz, gerçekte ise her düğümden merkeze giden görünmez oklarla doludur.

Global mutable state

draftBooking alanına arama ekranı, detay ekranı, deep link işleyicisi, push bildirimi işleyicisi ve bu ViewModel yazıyor. Beş yazar, sıfır sahip. Bir alan nil olduğunda "kim sildi" sorusunun cevabı breakpoint koyup beklemektir. Üstelik bu alanlara farklı thread'lerden erişiliyor ve çoğu projede bunu koruyan hiçbir şey yok; bugüne kadar patlamamış olması zamanlamanın insafıdır.

Test izolasyonu ve sıraya bağımlı testler

SessionManager.shared test süreci boyunca yaşar. Bir test currentUser atar ve temizlemeyi unutur; sonraki test "kullanıcı yokken checkout" senaryosunu sınarken bir kullanıcı bulur. Testler tek tek geçer, birlikte çalışınca kırılır, sıra değişince başka bir test kırılır. Ekiplerin buna bulduğu ilk çare resetForTesting() metodudur: üretim koduna test için eklenmiş, her yeni alanda güncellenmesi unutulan bir metot. Swift Testing testleri varsayılan olarak paralel çalıştırdığı için bu çare de orada işe yaramaz; iki test aynı anda aynı global'i sıfırlar.

Kullanıcı oturumundan uzun yaşayan state

En pahalı kategori budur. SessionManager süreç boyunca yaşıyor, içindeki verinin sahibi ise giriş yapmış kullanıcı. Logout kodu şöyle görünür:

func logout() {
    SessionManager.shared.currentUser = nil
    SessionManager.shared.authToken = nil
    // recentSearches? draftBooking? Geçen ay eklenen loyaltyPoints?
}

Logout doğruluğu, birinin her yeni alanı bu listeye eklemeyi hatırlamasına bağlıdır. Hatırlamadığında A kullanıcısı çıkar, B kullanıcısı girer ve A'nın son aramalarını görür. Bu bir güvenlik ve gizlilik hatasıdır, sebebi de tek bir tasarım kararıdır: oturum ömürlü veri, uygulama ömürlü bir nesnede duruyor.

Başlatma sırası

static let Swift'te ilk erişimde, thread-safe biçimde başlatılır (SE-0412 bunu açıkça belirtir). Kotlin object de aynı şekilde ilk erişimde başlatılır. Kulağa güvenli geliyor ama sonucu şu: sıra, ilk kimin dokunduğuna göre belirlenir. FeatureFlags.shared başlatılırken SessionManager.shared.currentUser'ı okuyorsa ve bir gün uygulama push bildirimiyle arka planda açılıp önce Analytics.shared'a dokunursa, başlatma zinciri sizin hiç test etmediğiniz bir sırayla çalışır. Bu bug'ların ortak özelliği yalnızca belirli bir açılış yolunda görülmeleridir.

Beş sorunun hiçbiri "bu nesneden bir tane var" cümlesinden doğmuyor. Hepsi erişimin gizli, state'in sahipsiz ve ömrün yanlış olmasından doğuyor.

Tek instance olarak kalması gereken şeyler

Bazı şeyler doğası gereği süreç genelindedir ve çoğaltılmaları zarar verir.

HTTP istemcisi bunların başında gelir. OkHttp'nin kendi dokümantasyonu açık: tek bir OkHttpClient yaratıp tüm çağrılarda yeniden kullanmanızı söyler, çünkü her istemci kendi bağlantı havuzunu ve thread havuzlarını tutar. Farklı timeout gereken yerde yeni istemci yaratmak yerine newBuilder() ile aynı havuzları paylaşan bir türev alınır. iOS'ta URLSession için de benzer mantık geçerlidir: oturum, bağlantıları ve cache'i sahiplenir. Yalnız URLSession.shared'ı Apple'ın belgelediği sınırlarıyla bilin: delegate'i ve özelleştirilebilir bir configuration'ı yoktur, kimlik doğrulama imkânı sınırlıdır, arka plan transferi yapamaz. Apple'ın kendi tavsiyesi, cache, cookie, kimlik doğrulama ya da özel protokollerle işiniz varsa kendi oturumunuzu yaratmanızdır. Yani ciddi bir uygulamada "tek URLSession" doğru karardır, o tek oturumun URLSession.shared olması ise genellikle yanlış.

Logger ikinci örnektir. State'i yoktur ya da append-only'dir, davranışı çağıranın kim olduğuna göre değişmez, testlerde gerçek hâliyle çalışması kimseyi rahatsız etmez. Logger'ı her sınıfa constructor'dan geçirmek, dogmanın en gereksiz ürünüdür. Bizim tercihimiz logger'ın global erişilebilir kalmasıdır; istisnası, log çıktısını testte doğrulamak istediğiniz nadir durumlardır.

Analytics pipeline'ı, crash raporlayıcı, disk cache'i, veritabanı bağlantısı da aynı ailedendir: tek instance olmaları doğrudur. Bunlarda logger'dan farklı olarak erişimi açık bırakmıyoruz, çünkü testte sahtesiyle değiştirmek istiyoruz ve hangi module'ün analytics'e bağlı olduğunu bağımlılık grafiğinde görmek istiyoruz.

Ayırt edici soru şu: bu nesnenin davranışı ya da içeriği, kimin giriş yaptığına ya da kullanıcının hangi akışta olduğuna göre değişiyor mu? Değişmiyorsa uygulama ömürlü tek instance uygundur. Değişiyorsa ömrü yanlış seçilmiştir.

Pratik orta yol: bir kez yarat, elden ver

Önerdiğimiz düzen basit: tek instance'lar composition root'ta bir kez yaratılır ve ihtiyacı olana constructor'dan verilir. Nesne hâlâ tektir; bunu artık static let yerine, onu yaratan tek bir yerin olması garanti eder.

protocol BookingAPI: Sendable {
    func quote(for draft: BookingDraft) async throws -> Money
    func book(_ draft: BookingDraft, price: Money) async throws
}

@MainActor
final class CheckoutViewModel {
    private let drafts: BookingDraftStore
    private let api: any BookingAPI
    private let flags: any FeatureFlagReading
    private let analytics: any AnalyticsTracking

    init(drafts: BookingDraftStore,
         api: any BookingAPI,
         flags: any FeatureFlagReading,
         analytics: any AnalyticsTracking) {
        self.drafts = drafts
        self.api = api
        self.flags = flags
        self.analytics = analytics
    }

    func confirm() async throws {
        guard let draft = drafts.current else { return }
        let price = if flags.isEnabled(.dynamicPricing) {
            try await api.quote(for: draft)
        } else {
            draft.listPrice
        }
        try await api.book(draft, price: price)
        analytics.track(.bookingConfirmed(draft.id))
        drafts.current = nil
    }
}

Dikkat ederseniz user parametresi kayboldu. BookingAPI zaten oturuma ait bir nesne olarak yaratıldığı için kimin adına konuştuğunu biliyor. Buna birazdan döneceğiz.

Geçiş adımı olarak default parametre

Mevcut bir projede dört yüz çağrı noktasını aynı gün değiştiremezsiniz. Default parametre burada işe yarar:

final class SearchViewModel {
    private let session: SessionManager
    private let api: APIClient

    init(session: SessionManager = .shared, api: APIClient = .shared) {
        self.session = session
        self.api = api
    }
}

Hiçbir çağrı noktası değişmez, sınıfın gövdesindeki .shared erişimleri kaybolur, test artık kendi instance'ını verebilir. Bu, bir PR'da bir sınıf ilerleyebileceğiniz bir adımdır ve o yüzden değerlidir.

Sınırlarını da bilin. Üretim tarafında bağımlılık hâlâ gizlidir: SearchViewModel() yazan kişi hiçbir şey görmez. Default değer somut tipi import etmeyi gerektirdiği için module sınırı kurmanıza yardım etmez. Testte argümanı unutan kişi sessizce gerçek singleton'ı kullanır. Swift 6'da bir kısıt daha gelir: SE-0411'e göre default değer ifadesi, fonksiyonun kendi izolasyonunda çalıştırılabilir olmalıdır; shared @MainActor ise ve init değilse kod derlenmez. En önemlisi, ömür sorununa hiç dokunmaz. Default parametre bizim için iskeledir: eklediğimiz gün, söküleceği adımı da plana yazıyoruz.

iOS: Swift 6 static let shared'a ne yapıyor

Swift 6 dil modunda (ya da Swift 5 modunda complete concurrency checking ile) SE-0412 kuralı devreye girer: her global ve static değişken ya bir global actor'a izole olmalıdır ya da hem değiştirilemez hem Sendable tipte olmalıdır. Global bir var yazdığınızda derleyicinin mesajı sorunu bizden iyi özetler:

error: global variable 'supportedStyleCount' is not concurrency-safe
because it is non-isolated global shared mutable state

static let shared = SessionManager() satırında değişkenin kendisi sabittir, ama SessionManager Sendable olmayan, içinde var alanlar taşıyan bir sınıf olduğu için aynı kural onu da reddeder. Derleyici, yıllardır review'da söylenen şeyi artık hata olarak söylüyor: bu nesne paylaşılan, korunmayan, değiştirilebilir state.

Dürüst çıkış yolları, state'in kime ait olduğuna göre değişir:

import Synchronization

// 1) Değişmeyen yapılandırma: değer tipi, Sendable, static let olarak kalabilir.
struct APIEnvironment: Sendable {
    let baseURL: URL
    let requestTimeout: TimeInterval

    static let production = APIEnvironment(
        baseURL: URL(string: "https://api.example.com")!,
        requestTimeout: 30
    )
}

// 2) Her yerden erişilen, değişen state: actor.
actor TokenStore {
    private var token: AuthToken?

    init(initial: AuthToken? = nil) { token = initial }

    func current() -> AuthToken? { token }
    func replace(with newToken: AuthToken?) { token = newToken }
}

// 3) Yalnızca UI'ın okuyup yazdığı state: main actor.
@MainActor
final class BookingDraftStore {
    var current: BookingDraft?
}

// 4) Senkron erişim şart, kritik bölüm kısa: Mutex (iOS 18 ve sonrası).
final class FeatureFlagCache: Sendable {
    private let values = Mutex<[String: Bool]>([:])

    func isEnabled(_ key: String) -> Bool {
        values.withLock { $0[key] ?? false }
    }

    func replaceAll(_ newValues: [String: Bool]) {
        values.withLock { $0 = newValues }
    }
}

Bir de dürüst olmayan çıkışlar var. nonisolated(unsafe) ve @unchecked Sendable derleyiciye "ben hallettim" demektir. Migration guide nonisolated(unsafe) için açık konuşur: yalnızca tüm erişimleri kilit ya da dispatch queue gibi harici bir mekanizmayla gerçekten koruyorsanız kullanın. Bir akşamda yüz hatayı bu iki anahtar kelimeyle susturan bir PR, hiçbir şeyi düzeltmeden uyarıları kapatmıştır.

SessionManager'ın başına @MainActor yazmak da cazip bir kısa yoldur. Derlenir, data race gerçekten ortadan kalkar. Ama yukarıdaki beş sorunun diğer dördü yerinde durur ve artık ağ katmanı token okumak için main actor'a uğramak zorundadır. Swift 6.2 ile gelen default isolation ayarı (SE-0466: derleyicide -default-isolation MainActor, SwiftPM'de defaultIsolation(MainActor.self)) bunu module genelinde otomatik yapar: işaretsiz global ve static değişkenler main actor'a izole sayılır. Projenizde bu ayarın açık olup olmadığını build ayarlarından kontrol edin; açıksa static let shared şikâyetsiz derlenir ve derleyicinin size sorduğu "bu state kimin" sorusu sessizce cevaplanmış olur. Cevabın doğru olup olmadığına yine siz bakmalısınız.

Oturum ömürlü ve uygulama ömürlü nesneleri ayırmak

Logout bug'larının kalıcı çözümü, oturuma ait her şeyi oturumla birlikte doğup ölen tek bir nesnenin altına toplamaktır:

@MainActor
final class AppDependencies {
    let environment: APIEnvironment
    let urlSession: URLSession
    let analytics: any AnalyticsTracking
    let flags: FeatureFlagCache

    init(environment: APIEnvironment = .production) {
        self.environment = environment
        let configuration = URLSessionConfiguration.default
        configuration.timeoutIntervalForRequest = environment.requestTimeout
        self.urlSession = URLSession(configuration: configuration)
        self.analytics = AnalyticsPipeline()
        self.flags = FeatureFlagCache()
    }

    func makeUserSession(user: User, token: AuthToken) -> UserSession {
        UserSession(user: user, token: token, app: self)
    }
}

@MainActor
final class UserSession {
    let user: User
    let api: any BookingAPI
    let drafts = BookingDraftStore()
    let recentSearches = RecentSearchStore()
    private let app: AppDependencies

    init(user: User, token: AuthToken, app: AppDependencies) {
        self.user = user
        self.app = app
        self.api = AuthenticatedBookingAPI(
            urlSession: app.urlSession,
            environment: app.environment,
            tokens: TokenStore(initial: token)
        )
    }

    func makeCheckoutViewModel() -> CheckoutViewModel {
        CheckoutViewModel(drafts: drafts, api: api,
                          flags: app.flags, analytics: app.analytics)
    }
}

@MainActor
final class AppRoot {
    let app = AppDependencies()
    private(set) var session: UserSession?

    func didLogin(user: User, token: AuthToken) {
        session = app.makeUserSession(user: user, token: token)
    }

    func logout() {
        session = nil   // oturuma ait her şey bu satırla birlikte gider
    }
}

logout() artık bir alan listesi tutmuyor. Gelecek ay birisi UserSession'a loyaltyPoints eklediğinde logout'u güncellemesi gerekmiyor, çünkü unutulabilecek bir şey kalmadı. Giriş yapılmamışken checkout ekranı açmak da derleme zamanında zorlaşıyor: CheckoutViewModel üretmenin tek yolu elinizde bir UserSession olması.

Android: object, Hilt scope'ları, process death ve Context

Android tarafında aynı tablo Kotlin object ile çizilir:

object SessionManager {
    var currentUser: User? = null
    var authToken: String? = null
    var draftBooking: BookingDraft? = null
    lateinit var context: Context          // MainActivity.onCreate içinde atanıyor

    fun init(context: Context) { this.context = context }
}

class CheckoutViewModel : ViewModel() {
    fun confirm() = viewModelScope.launch {
        val user = SessionManager.currentUser ?: return@launch
        val draft = SessionManager.draftBooking ?: return@launch
        ApiClient.instance.book(draft, user.id)
        Analytics.track(BookingConfirmed(draft.id))
        SessionManager.draftBooking = null
    }
}

iOS bölümündeki beş sorunun hepsi burada da var. Android bunlara iki tane daha ekler.

Singleton kalıcı depolama değildir

Kullanıcı üç ekranda rezervasyon taslağını doldurur, ödeme adımında SMS kodunu almak için banka uygulamasına geçer. Sistem bellek ihtiyacı yüzünden sizin process'inizi öldürür. Kullanıcı döndüğünde Android activity yığınını ve kaydedilmiş instance state'i geri getirir, yani kullanıcı kendini checkout ekranında bulur. Yeni process'te SessionManager sıfırdan yaratılmıştır; draftBooking null'dır ve yukarıdaki confirm() hiçbir şey söylemeden döner. Geliştirici bunu masasında neredeyse hiç görmez, çünkü test cihazında bellek baskısı yoktur.

Android dokümantasyonundaki tablo açıktır: ViewModel sistem kaynaklı process death'ten sağ çıkmaz, saved state ve kalıcı depolama çıkar. object ve @Singleton de bellekte yaşar, aynı kaderi paylaşır. Kuralımız: süreç ömürlü bir nesne, diskte ya da saved state'te duran gerçeğin önbelleği olabilir, kendisi gerçeğin kaynağı olamaz. Akışa ait UI state'i SavedStateHandle'a, uygulama verisi (taslak, token) DataStore ya da veritabanına gider. Bunu kendi projenizde sınamak için geliştirici seçeneklerindeki "Don't keep activities" yetmez; uygulamayı arka plana alıp adb shell am kill <paket> ile process'i öldürün ve son uygulamalar listesinden geri dönün.

Activity tutan singleton

lateinit var context: Context alanına bir Activity atandığında, o activity ve ona bağlı bütün view ağacı process ölene kadar bellekte kalır. Ekran her döndüğünde bir tane daha. Android lint'in StaticFieldLeak kontrolü tam da bunun içindir ("Do not place Android context classes in static fields"). Süreç ömürlü bir nesnenin tutabileceği tek Context, application context'tir; Hilt'te bunun için @ApplicationContext qualifier'ı vardır. Bir singleton'ın dialog göstermek ya da navigation yapmak için activity'ye ihtiyacı varsa, o iş singleton'a ait değildir.

Hilt: @Singleton bir ömür kararıdır

Hilt'te tek instance kararı composition root'a, yani module'lere taşınır:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor(authInterceptor)
            .build()

    @Provides
    @Singleton
    fun provideBookingApi(client: OkHttpClient): BookingApi =
        Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(client)
            .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
            .build()
            .create(BookingApi::class.java)
}

@HiltViewModel
class CheckoutViewModel @Inject constructor(
    private val sessionHolder: SessionHolder,
    private val analytics: AnalyticsTracker,
    private val savedState: SavedStateHandle,
) : ViewModel()

OkHttpClient hâlâ tektir ama artık ona ulaşmanın tek yolu constructor'da istemektir. Hilt'te binding'ler varsayılan olarak scope'suzdur: her istekte yeni instance yaratılır. Dokümantasyon scope'lamanın maliyetli olabileceğini, çünkü nesnenin component yok edilene kadar bellekte kaldığını söyler ve scope'u üç durum için önerir: aynı instance'ın paylaşılmasını gerektiren iç state, senkronizasyon ihtiyacı ya da yaratmanın pahalı olduğunu ölçtüğünüz nesneler. State'siz bir mapper'ın ya da use case'in üstündeki @Singleton, "önemli sınıf" anlamında kullanılmış bir etikettir; kaldırın. Aynı şekilde bir akışa ait state için @ViewModelScoped ya da @ActivityRetainedScoped çoğu zaman @Singleton'dan daha doğru ömürdür. Android tarafındaki diğer yaygın hataları Android'de DI anti-pattern'leri yazısında topladık.

Test tarafında bir fark daha var: Hilt her test için kendi component setini kurar, yani @Singleton binding'ler testler arasında taşınmaz. Kotlin object ise test JVM'i yaşadığı sürece yaşar ve state'ini bir test sınıfından diğerine taşır.

Android'de oturum scope'u

Hilt'in standart component'leri arasında "giriş yapmış kullanıcı" yoktur. @DefineComponent ile özel bir component tanımlanabilir, ancak Dagger dokümantasyonu bu konuda temkinlidir: her yeni component bilişsel yük getirir, grafiği karmaşıklaştırır ve standartlaşmaya karşı çalışır. Bizim ilk tercihimiz daha sade: oturumu düz bir nesne grafiği olarak kurmak ve uygulama ömürlü tek bir tutucunun arkasına koymak.

class UserSession(
    val user: User,
    val bookingRepository: BookingRepository,
    val draftStore: DraftStore,
    private val scope: CoroutineScope,
) {
    fun close() = scope.cancel()
}

@Singleton
class SessionHolder @Inject constructor(
    private val factory: UserSessionFactory,
) {
    private val _session = MutableStateFlow<UserSession?>(null)
    val session: StateFlow<UserSession?> = _session.asStateFlow()

    fun start(user: User, token: AuthToken) {
        val next = factory.create(user, token)
        _session.getAndUpdate { next }?.close()
    }

    fun end() {
        _session.getAndUpdate { null }?.close()
    }
}

SessionHolder tek instance'tır ve öyle kalmalıdır. İçindeki UserSession ise login'de doğar, logout'ta ölür, kendi coroutine scope'unu da yanında götürür. Process death sonrası SessionHolder boş başlar; uygulama açılışında kalıcı depodaki token'dan oturumu yeniden kurmak sizin işinizdir ve bu, yukarıdaki önbellek kuralının doğrudan sonucudur. Oturum grafiği onlarca sınıfa ulaşıp elle kurmak yük olmaya başladığında özel component'i yeniden değerlendirin.

Büyük yeniden yazım olmadan göç

Aşağıdaki sıra her iki platformda da aynı şekilde işliyor. Her adım kendi başına merge edilebilir.

  1. Envanter çıkarın. iOS'ta static let shared, static var shared bildirimlerini ve .shared erişimlerini, Android'de object bildirimlerini, companion object içindeki instance alanlarını ve @Singleton kullanımlarını sayın. Düz bir grep yeter. Çıkan sayıyı bir yere yazın; ilerlemeyi bununla ölçeceksiniz.
  2. Her birini sınıflandırın: state'siz mi, uygulama ömürlü state mi, oturum ömürlü state mi, akış ömürlü state mi, yoksa UI nesnesi mi tutuyor. Yazının sonundaki tablo bu adım için.
  3. Yeni erişimi durdurun. İş mantığı ve UI katmanlarında yeni .shared kullanımını lint kuralıyla ya da review kuralıyla yasaklayın; mevcut olanları baseline'a alın. Kanamayı durdurmadan temizlik bitmez. Kuralı otomatiğe bağlamanın yollarını mimari kurallarını zorlamak yazısında anlatıyoruz.
  4. Yapraklardan başlayın. En çok değişen ya da en çok test etmek istediğiniz ViewModel'leri seçin, default parametreyle bağımlılıkları constructor'a taşıyın. Çağrı noktaları değişmez.
  5. Yaratmayı yukarı itin. Bir ekranı üreten yer (coordinator, navigation graph, factory) bağımlılıkları açıkça vermeye başlasın. Bir özelliğin bütün çağrı noktaları açık hâle gelince o sınıflardaki default'ları silin.
  6. Oturumu ayırın. UserSession'ı yaratın ve SessionManager'daki alanları birer birer oraya taşıyın. Geçiş süresince SessionManager.shared.draftBooking aktif oturuma yönlenen, deprecated işaretli bir computed property olarak kalabilir. Son çağıran gittiğinde alanı silin.
  7. shared'ı kapatın. Composition root dışında kullanılmayan static let shared'ları silin ya da erişim seviyesini düşürün. Android'de object'i @Singleton sınıfa çevirin.

İşe yaradığını şuradan anlarsınız: testleri rastgele sırada ve paralel çalıştırdığınızda (Xcode test plan'ında rastgele sıra seçeneği, Swift Testing'in varsayılan paralelliği, Gradle'da maxParallelForks) sonuç değişmiyor. Envanter sayısı her sprint düşüyor. "A çıkar, B girer, A'ya ait hiçbir şey görünmez" senaryosu için yazdığınız test, logout() içinde alan listesi olmadan geçiyor. Android'de process'i öldürüp geri döndüğünüzde kullanıcı kaldığı yerden devam ediyor.

Nerede durmalı

Hedef grep sonucunu sıfıra indirmek olmamalı. Logger'ı, APIEnvironment.production gibi değişmez yapılandırmayı ya da sistemin kendi singleton'larına ince bir sarmalayıcıyı enjekte etmek için harcanan emek geri dönmez. Tek hedefli, üç ekranlı bir uygulamada composition root kurmak da çoğu zaman erken yatırımdır; orada yalnızca oturum ayrımını ve Android'de process death kuralını uygulamak yeterlidir. Sınır şu: bir singleton state'siz ise, testte gerçeğiyle çalışması sorun değilse ve module sınırlarını delmiyorsa, bırakın kalsın.

Karar tablosu

Ne Belirti Karar
Logger State'siz, davranışı çağırana göre değişmiyor Tek instance kalsın, global erişim kabul
URLSession / OkHttpClient Bağlantı ve thread havuzu tutuyor Tek instance kalsın, composition root'ta yaratıp enjekte edin
Analytics, crash raporlama, veritabanı Süreç geneli kaynak, testte sahtesi gerekiyor Tek instance kalsın, arayüz arkasından enjekte edin
Feature flag önbelleği Değişen ama kullanıcıdan bağımsız state Tek instance, eşzamanlılığı korunmuş (actor, Mutex), enjekte edin
Kullanıcı, token, kullanıcıya özel cache Logout'ta temizlenmesi gereken her şey Oturuma bağlayın (UserSession)
Sepet, rezervasyon taslağı, form state'i Bir akış boyunca anlamlı Oturuma ya da akışa bağlayın, Android'de ayrıca kalıcı hâle getirin
AppState.shared, DataManager, ilgisiz alanlar yığını Beş yazar, sıfır sahip Kaldırın: alanları sahiplerine dağıtın
Activity, ViewController ya da View tutan singleton Bellek sızıntısı, yanlış ömür Kaldırın
State'siz mapper, formatter, use case üstünde @Singleton / shared Paylaşılacak state yok Singleton'lığı kaldırın, scope'suz bırakın

Kontrol listesi

  • Projedeki shared, object ve @Singleton kullanımlarının sayısı çıkarıldı ve bir yere yazıldı.
  • Her biri tablodaki kararlardan biriyle etiketlendi.
  • İş mantığı ve UI katmanında yeni global erişim lint ya da review kuralıyla durduruldu.
  • Oturuma ait bütün state tek bir nesnenin altında; logout o nesneyi bırakmaktan ibaret.
  • iOS: Swift 6 hataları nonisolated(unsafe) ve @unchecked Sendable ile susturulmadı; her kullanımın yanında neyin koruduğu yazıyor.
  • iOS: module'ün default isolation ayarı biliniyor ve bilinçli seçilmiş.
  • Android: hiçbir object ya da @Singleton, Activity, Fragment ya da View tutmuyor.
  • Android: process öldürülüp geri dönüldüğünde kritik akışlar (ödeme, form) kaldığı yerden devam ediyor.
  • Testler rastgele sırada ve paralel çalıştırıldığında sonuç aynı.
  • Default parametreyle eklenen her geçici = .shared için kaldırma adımı planda.

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.