Yazılarımız

Veri Akademi

ANDROİD JETPACK VE CLEAN ARCHİTECTURE: KATMANLAR, DI VE TEST EDİLEBİLİRLİK

Android ekosistemi hızla büyürken uygulamaların sürdürülebilir kalması her zamankinden daha önemli. Jetpack bileşenleri “doğru araçları” sunuyor, ancak mimari kararlar net değilse proje kısa sürede karmaşıklaşabiliyor.

Clean Architecture, sorumlulukları katmanlara ayırarak bağımlılık yönünü tersine çevirir ve değişime dayanıklı bir yapı kurmayı hedefler. Jetpack ile birleştiğinde, özellikle MVVM + Use Case yaklaşımıyla daha okunabilir, test edilebilir ve ölçeklenebilir bir kod tabanı oluşur.

Bu yazıda, Android Jetpack Clean Architecture yaklaşımını uçtan uca ele alacağız: katmanlar nasıl ayrılır, Hilt ile dependency injection nasıl kurgulanır ve test edilebilirlik için hangi pratikler işe yarar. Daha fazlası için Android Kotlin Eğitimi sayfasına da göz atabilirsiniz.

Jetpack bileşenleriyle katmanların sorumluluklarını ayıran modern Android uygulama yapısı

Android Jetpack Clean Architecture yaklaşımı neden önemli?

Bakım maliyeti ve ekip ölçeği büyüdükçe ortaya çıkan sorunlar

Uygulama büyüdükçe “her şeyin ViewModel’da çözülmesi”, Activity/Fragment içinde ağ çağrıları, veritabanı işlemleri ve dönüşüm mantığı gibi anti-pattern’ler kendini gösterir. İlk sürümler hızlı çıkar; fakat yeni özellik eklemek, hata ayıklamak ve regresyon riskini yönetmek giderek zorlaşır. Clean Architecture, bu riski katman sınırlarıyla azaltır.

Jetpack ile uyumlu, modüler ve test dostu yapı

Jetpack; Lifecycle, ViewModel, Navigation, Room, DataStore gibi bileşenlerle standartlaşmış bir geliştirme deneyimi sağlar. Clean Architecture ise bu bileşenleri “doğru yerde” kullanmayı kolaylaştırır. Böylece UI bağımlılıkları ile iş kuralları ayrılır; testler daha hızlı ve daha güvenilir hale gelir.


Katmanlar: Presentation, Domain, Data

Bağımlılık yönü: dış katmanlar iç katmanlara bağımlıdır

Clean Architecture’ın temel fikri, iç katmanların dış katmanları bilmemesidir. Domain katmanı; UI framework’lerini, veritabanını, ağ kütüphanesini tanımaz. Data katmanı ise dış dünyayla konuşur ama Domain’e uygun sözleşmeleri (interface) uygular. Presentation katmanı (UI) Domain’i kullanır; iş kuralı ve karar mekanizması burada değil, Domain’dedir.

Presentation: UI state, olaylar ve ViewModel sınırları

Presentation katmanı; Compose ya da XML tabanlı UI, ViewModel, UI state ve kullanıcı etkileşimlerinin yönetildiği yerdir. Burada amaç, veri dönüşümlerini minimal tutmak ve ekranın ihtiyacını “durum” üzerinden ifade etmektir. Örneğin loading/error/content gibi durumlar net olmalıdır. UI katmanı, Repository ya da Retrofit ile doğrudan konuşmamalı; bunun yerine Use Case çağırmalıdır.

Domain: Use Case ve entity tasarımı

Domain katmanı, uygulamanın iş kurallarını temsil eder. Use Case (Interactor) yaklaşımı, “yapılacak işi” tek bir sorumlulukla tanımlar: kullanıcı listesini getir, favoriyi değiştir, siparişi onayla gibi. Domain entity’leri, framework bağımlılığı olmadan, sade veri yapıları ve kurallar içerir. Bu sayede birim testleri hızlı çalışır ve dış bağımlılıklar azaltılır.

Data: Repository, data source ve mapping

Data katmanı, ağ (Retrofit), yerel saklama (Room, DataStore) gibi kaynaklarla iletişimi yönetir. Repository pattern; farklı kaynakları tek bir “veri kapısı” altında birleştirir. Data modelleri (DTO/Entity) ile Domain modelleri arasında mapping yapılır. Bu dönüşüm noktaları netse, veri kaynağı değişse bile Domain ve UI minimum etkilenir.

Presentation Domain ve Data katmanlarının bağımlılık yönünü gösteren düzenli modül ayrımı örneği

Dependency Injection: Hilt ile bağımlılıkları yönetmek

DI olmadan yaşanan sıkıntılar ve Hilt’in katkısı

Bağımlılıkları elle oluşturmak, özellikle Repository’nin altındaki API servisleri, veritabanı, dispatcher’lar ve mapper’lar çoğaldıkça karmaşayı artırır. Hilt, Android bileşenleriyle entegre bir DI çözümü sunarak nesne yaşam döngüsünü yönetir ve testlerde fake/mock enjekte etmeyi kolaylaştırır.

Katmanlar arası sözleşmeler: interface’ler Domain’de yaşar

Repository sözleşmesini Domain’de tanımlamak, bağımlılık yönünü doğru kurar. Data katmanı bu interface’i uygular; Presentation katmanı ise Use Case üzerinden Domain’e erişir. Böylece UI, verinin nereden geldiğini umursamaz; yalnızca sonucu tüketir.

Hilt modülleri: doğru scope ve doğru sağlayıcılar

Uygulama geneli bağımlılıklar için Singleton scope tercih edilir. Network client, database instance gibi maliyetli nesneler tekil olmalıdır. Dispatcher’lar gibi küçük bağımlılıkları da açıkça sağlayarak testlerde kontrol edilebilir hale getirmek iyi bir pratiktir.

// Domain katmanı: sözleşme
interface UserRepository {
    suspend fun refreshUsers(): Result<Unit>
    fun observeUsers(): kotlinx.coroutines.flow.Flow<List<User>>
}

// Domain modeli
data class User(
    val id: String,
    val name: String,
    val isActive: Boolean
)

// Use Case: tek sorumluluk
class ObserveUsersUseCase(
    private val repository: UserRepository
) {
    operator fun invoke(): kotlinx.coroutines.flow.Flow<List<User>> = repository.observeUsers()
}
// Data katmanı: Hilt modülü (örnek)
@dagger.Module
@dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class)
object DataModule {

    @dagger.Provides
    @javax.inject.Singleton
    fun provideUserRepository(
        api: UserApi,
        dao: UserDao,
        mapper: UserMapper
    ): UserRepository = UserRepositoryImpl(api, dao, mapper)
}

// Presentation katmanı: ViewModel kullanımı (özet)
@dagger.hilt.android.lifecycle.HiltViewModel
class UsersViewModel @javax.inject.Inject constructor(
    private val observeUsers: ObserveUsersUseCase
) : androidx.lifecycle.ViewModel() {

    val users = observeUsers()
        .stateIn(
            viewModelScope,
            kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000),
            emptyList()
        )
}

Veri kaynakları: Room, Retrofit ve DataStore ile tutarlı akış

Offline-first yaklaşımı ve tek kaynak (single source of truth)

Birçok uygulamada en iyi deneyim, yerel veriyi “tek gerçek kaynak” olarak kullanıp ağdan senkronlamakla gelir. UI, doğrudan API sonucunu dinlemek yerine Room üzerinden akış (Flow) tüketir. Ağ çağrıları, veritabanını günceller; UI otomatik yenilenir. Bu yaklaşım, “boş ekran” anlarını azaltır ve performansı iyileştirir.

Room ve mapper stratejileri

Room entity’leri, tablo ihtiyaçlarına göre şekillenir. Domain modeli ise iş ihtiyacına göre sade kalır. Mapping’i Data katmanında tutmak; UI ile database şemasını birbirine bağlamayı engeller. Ayrıca migration ve şema değişiklikleri daha kontrollü yönetilir.

Retrofit, hata haritalama ve Result tipi

Network hatalarını doğrudan UI’ya taşımak yerine, Data katmanında anlamlı bir hata modeline çevirmek daha sağlıklı olur. Örneğin “internet yok”, “sunucu hatası”, “yetkisiz erişim” gibi durumlar ayrı ele alınabilir. Use Case, bu sonucu UI state’e dönüştürürken daha net karar verir.

DataStore ile ayarların katmanlı yönetimi

Basit anahtar-değer verileri için DataStore, SharedPreferences’e göre daha modern ve coroutine uyumlu bir alternatiftir. Tema seçimi, dil tercihi, onboarding tamamlandı bilgisi gibi durumlar için uygun bir seçenektir. Bu yapı, Domain’e doğrudan sızmamalı; Data katmanında soyutlanmalıdır.

Yerel veritabanı akışı ile ağ senkronizasyonunun birlikte çalıştığı, kullanıcı listesi senaryosuna odaklı yapı

Test edilebilirlik: Unit, Integration ve UI test stratejisi

Unit test: Domain katmanında hızlı ve güvenilir testler

En yüksek getirili testler genelde Domain katmanında yazılır. Use Case’ler saf iş mantığı içerdiği için mock’lanmış repository ile kolayca test edilebilir. Bu testler hızlı çalışır, CI içinde güven verir ve refactor sırasında hata yakalama olasılığı yüksektir.

Integration test: Data katmanında sözleşme doğrulama

Room DAO testleri veya Retrofit mock server testleri gibi çalışmalar, Data katmanının doğru çalıştığını kanıtlar. Burada amaç “tüm uygulamayı” test etmek değil; veri akışının ve mapping’in beklendiği gibi olduğunu doğrulamaktır. Uygun bir test altyapısı kurulduğunda, üretim ortamı sürprizleri ciddi oranda azalır.

UI test: ekran davranışı ve Navigation akışları

UI testleri, kritik kullanıcı akışlarını doğrulamak için değerlidir: liste açılıyor mu, hata durumunda uyarı gösteriliyor mu, arama filtresi çalışıyor mu gibi. Ancak maliyeti yüksektir; bu nedenle daha az sayıda, daha hedefli tutulması genellikle daha verimlidir. Compose kullanıyorsanız semantik etiketleme, testlerin kararlılığını artırır.

İyi bir test piramidi için genel öneri; çok sayıda unit test, orta sayıda integration test ve az sayıda UI testle ilerlemektir. Ayrıca deterministik testler için coroutine dispatcher’ları kontrol etmek önemlidir.

Mock ve fake ayrımı: ne zaman hangisi?

Mock, bir bağımlılığın davranışını beklentiyle tanımlayıp doğrulamak için kullanılır. Fake ise daha basit bir “in-memory” implementasyonla gerçek davranışa yaklaşır. Use Case testlerinde mock repository sık kullanılırken, bazı senaryolarda fake repository ile daha doğal bir test akışı kurulabilir.

Örnek kurgusu: Feature bazlı modüler yapı ve akış

Modül sınırları: core, data, domain, feature

Proje ölçeği büyüdükçe feature bazlı modüler yaklaşım, bağımlılıkları ve build sürelerini yönetmeyi kolaylaştırır. Örneğin core-common (utility), core-network, core-database gibi ortak modüller ile feature-users, feature-profile gibi ekran bazlı modüller ayrılabilir. Bu yapı, ekiplerin paralel çalışmasını kolaylaştırır.

UI state modelleme: hatayı ve yüklenmeyi görünür kılmak

UI katmanında “ham veri” yerine state modellemek, ekran davranışını netleştirir. Örneğin boş liste, yükleniyor, hata, içerik durumları ayrı ele alınır. Böylece UI, if-else kalabalığına dönüşmeden okunabilir kalır.

Uygulamada sık işe yarayan pratikler:

  • Tek sorumluluk: Use Case tek iş yapsın, ViewModel sadece state yönetsin.
  • Mapping noktalarını belirgin tutun: DTO → Entity → Domain dönüşümü gözden kaçmasın.
  • Dispatcher’ları enjekte edin: IO/Main ayrımı test kontrolünü kolaylaştırır.
  • Repository içinde kaynak önceliği belirleyin: cache mi, ağ mı, ne zaman senkron?
  • Hata türlerini sınıflandırın: UI’ya anlamlı mesaj taşımak için ortak model kullanın.

Gerçekçi bir test örneği: Use Case doğrulama

Coroutine test ortamı ve doğrulama mantığı

Coroutine kullanan kodlarda testlerin güvenilir olması için test dispatcher’ı kullanmak kritiktir. Aksi halde zamanlama sorunları, flaky testlere yol açabilir. Aşağıdaki örnek, repository’den gelen Flow’un beklenen veriyi yayınladığını doğrular. Burada yaklaşım; iş mantığını küçük parçalara ayırmak ve her parçayı doğrulamaktır.

// Unit test (özet): ObserveUsersUseCase
class ObserveUsersUseCaseTest {

    private val repository = io.mockk.mockk<UserRepository>()
    private lateinit var useCase: ObserveUsersUseCase

    @org.junit.Before
    fun setUp() {
        useCase = ObserveUsersUseCase(repository)
    }

    @org.junit.Test
    fun `should emit users from repository`() = kotlinx.coroutines.test.runTest {
        val expected = listOf(
            User(id = "1", name = "Ada", isActive = true),
            User(id = "2", name = "Ege", isActive = false)
        )

        io.mockk.every { repository.observeUsers() } returns kotlinx.coroutines.flow.flowOf(expected)

        val result = useCase().first()

        org.junit.Assert.assertEquals(expected, result)
        io.mockk.verify(exactly = 1) { repository.observeUsers() }
    }
}

Test edilebilirlik yalnızca test yazmakla değil, tasarım kararlarıyla başlar. DI sayesinde bağımlılıkları değiştirmek kolaylaşır; katman sınırları ise yan etkileri kontrol altında tutar. Bu nedenle, mimariyi kurarken “test nasıl yazılacak?” sorusunu tasarımın bir parçası yapmak uzun vadede ciddi fayda sağlar.

Hilt ile enjekte edilmiş bağımlılıkların test ortamında değiştirildiği, düzenli paket yapısına sahip Android proje görünümü

Sık yapılan hatalar ve sağlam pratikler

Katmanları karıştırmak: UI’dan Data’ya kısa devre

En yaygın hata, UI katmanının doğrudan DAO veya API çağırmasıdır. Bu kısa vadede “kolay” görünse de, ekran sayısı arttıkça kontrolü kaybettirir. Use Case üzerinden Domain’e gitmek; değişiklikleri tek bir yerde toplar ve tekrar kullanım sağlar.

Repository’yi “her şeyi yapan sınıf”a çevirmek

Repository, veri erişimini yönetmeli; ancak iş kurallarını üstlenmemelidir. Kurallar Domain’de kalırsa, farklı ekranlar aynı mantığı güvenle paylaşabilir. Aksi halde Repository, kontrol edilmesi zor bir “dev sınıf” haline gelir.

Mapping’i UI’ya taşımak

DTO’yu doğrudan UI’da kullanmak, API değişikliklerini UI’ya yansıtır. Bu da her değişiklikte ekranları güncellemeyi zorunlu kılar. Mapping’i Data katmanında tutarak UI’yı daha stabil hale getirebilirsiniz.

Sonuç: Jetpack ile Clean Architecture’ı birlikte düşünmek

Android Jetpack Clean Architecture yaklaşımı, yalnızca “daha temiz kod” için değil; ekip büyüdüğünde, ürün hızlandığında ve değişiklik baskısı arttığında uygulamanın ayakta kalması için önemlidir. Katman sınırları net, bağımlılıklar doğru yönde ve DI ile yönetilebilir olduğunda, testler doğal bir çıktı haline gelir.

Başlangıçta biraz daha fazla tasarım gerektirse de, uzun vadede daha düşük bakım maliyeti, daha az hata ve daha rahat refactor anlamına gelir. Jetpack bileşenlerini doğru katmanda kullanarak, uygulamanızın gelişimini daha güvenli ve öngörülebilir bir çizgiye taşıyabilirsiniz.

 VERİ AKADEMİ