Android uygulamasını modülerleştirmek, tek Gradle modülünü sorumluluğu ve bağımlılık kuralı belirli daha küçük modüllere ayırmaktır. Pratikte ince bir :app giriş noktası, kullanıcıya dönük alanlar için :feature:*, ortak kütüphaneler için :core:* ve repository/veri kaynaklarını sahiplenen data modülleri işe yarar. Clean Architecture bu yapıyla uyumludur: presentation feature'da, domain sözleşmeleri saf Kotlin core modülünde, bunların uygulamaları data katmanındadır. Sistemin tutarlılığını tek yönde akan bağımlılıklar sağlar.
Üretimdeki bankacılık uygulamaları dahil çok modüllü projeler geliştirip düzenlerken modül graph'ının bir kez çizilen diyagram değil, her pull request'te korunan kısıt olduğuna ikna oldum. Bu yazıda önemli sınırları, okların yönünü ve modüler projeyi ek adımlı monolite döndüren hataları ele alıyorum.
Temel noktalar
- Dört rol:
:appgiriş,:feature:*dikey ürün alanları,:core:*yatay ortak kütüphaneler, data modülleri repository ve veri kaynaklarıdır. - Tek yön: app feature'ları, feature'lar core/data'yı görür; alt katmanlar yukarı bağımlı olmaz. Feature modülü başka feature'a bağımlı olmamalıdır.
- Mimari fiziksel hale gelir: domain Android tipi içermeyen saf Kotlin modülüdür; data ona bağımlıdır.
- Convention plugin'ler build mantığını ortaklaştırır; her modülde aynı ayarları tekrarlamazsınız.
- Üç yaygın hata: her şeyi bilen
:core, döngüsel bağımlılık ve data modelinin UI'ya sızması.
Modülerleştirme ne kazandırır?
İlk kazanç build süresidir. Gradle değişen girdilere sahip modülleri yeniden derleyebilir ve bağımsız işleri paralel yürütebilir. Tek modülde settings ekranındaki değişiklik geniş alanı etkiler. Ayrı :feature:settings modülünde değişen modül ve gerekli bağımlıları derlenir; ABI uyumlu değişiklik bağımlıların tekrar derlenmesini bile gerektirmeyebilir. Diğer feature çıktıları cache'te kalır. Büyük projede bunun incremental build süresine etkisi belirgin olabilir; süreyi gerçek projede ölçmek gerekir.
İkinci kazanç ekip ölçeğidir. :feature:checkout ve :feature:search üzerinde çalışan iki geliştirici daha az çakışır, reviewer değişikliği modülün public sözleşmesiyle değerlendirebilir. eID SDK'sında okuma, doğrulama ve entegrasyonu ayırırken de amaç buydu: sınırın arkasını çağıranı değiştirmeden geliştirmek.
Modül çizmek, iyi mimariyi kendiliğinden üretmez. Modüller sınırları uygular; içlerindeki kodun tasarımını sizin yapmanız gerekir.
Modül haritası: :app, :feature, :core, :data
Google'ın Android modülerleştirme rehberindeki roller pratikte de benzer biçimde ortaya çıkar.
:app giriş noktasıdır
App modülü feature'ları bağlar, root navigation graph'ı barındırır ve dependency injection graph'ını kurar. İş mantığı çok az olmalıdır. Birden fazla form factor varsa :app, :app:wear, :app:tv gibi ayrı app modülleri platform bağımlılıklarını ayırabilir. Küçük bir projenin settings.gradle.kts dosyası şöyle olabilir:
// settings.gradle.kts
includeBuild("build-logic")
include(":app")
include(":core:designsystem")
include(":core:domain")
include(":core:data")
include(":core:model")
include(":feature:articles")
include(":feature:bookmarks")
:feature dikey ürün alanıdır
Feature, ekranları, ViewModel'leri, UI state'i ve giriş/çıkış navigasyonuyla kendi içinde anlamlı ürün parçasıdır. Tek ekran olmak zorunda değildir; checkout içindeki sepet, adres ve onay adımları birlikte değişiyorsa aynı feature'a ait olabilir.
Bir feature başka feature'a bağımlı olmamalıdır. :feature:checkout, :feature:search sınıfını import ettiği anda bağımsız alanlar birbirine bağlanır ve döngü riski başlar. İkisinin gerçekten paylaşması gereken yapı ortak core modülüne taşınmalıdır.
:core yataydır ve ortak kodu taşır
Design system, network altyapısı, Kotlin yardımcıları ve analytics gibi birçok feature'ın kullandığı kod core'dadır. Core tek bir mimari katman demek değildir. Örneğin :core:designsystem, tema, tipografi ve yeniden kullanılan composable'ları sahiplenir. Geri tuşu davranışını tek yardımcıya taşımak gibi ortak UI davranışları da burada konumlanabilir.
Core başka core modüllerine bağımlı olabilir; feature veya app modülüne geri bağımlı olmamalıdır.
Data modülleri repository'leri sahiplenir
Data modülü repository, uzak/yerel veri kaynakları ve network/database modellerini kapsar. Çağıran, verinin Retrofit, Room veya bellek cache'inden geldiğini bilmez. Bu sınır, veri kaynağı veya serialization biçimini feature'lara dokunmadan değiştirmeyi sağlar.
Esas mesele bağımlılık yönüdür
Kuralı tek şemada görebiliriz:
:app → :feature:* → :core:* , :data:*
(never → :feature:*)
App feature'ları; feature'lar core ve data'yı görür. Core/data yukarıyı, feature da başka feature'ı görmez. Döngüsüz graph paralel build ve bağımsız incelemeyi mümkün kılar.
Gradle'da api ile implementation farkı bu sınırları korumaya yardım eder. implementation, bağımlılığı modülün tüketicisinin compile classpath'inden saklar. api ise tüketiciye ve transitive bağımlılıklara görünür kılar. Varsayılan olarak implementation kullanmak, bağımlılığın birkaç modül boyunca istemeden sızmasını önler.
// core/network/build.gradle.kts
dependencies {
// This type appears in the module's own public signatures,
// so consumers must see it → api.
api(project(":core:model"))
// Retrofit and OkHttp are internal plumbing. Keep them off
// every consumer's compile classpath → implementation.
implementation(libs.retrofit)
implementation(libs.okhttp)
}
Yalnız public imzada gerçekten görünen tipler için api kullanın. Her bağımlılığı otomatik olarak api yapmak, eski compile düzeninin her şeyi görünür kılan yapısını yeniden kurar.
Clean Architecture katmanları Gradle modüllerine nasıl yerleşir?
Presentation, domain ve data ayrımında kaynak seviyesindeki bağımlılıklar domain'e doğru bakmalıdır. Dependency inversion fikrini fiziksel modüllere uygulamak, mimari kuralı derleyicinin denetleyebilmesini sağlar.
- Presentation:
:feature:*içinde ViewModel, UI state ve composable'lar. - Domain:
:core:domaingibi Android bağımlılığı olmayan saf Kotlin modülü; use case, entity ve repository arayüzleri. - Data:
:core:dataveya:data:*; repository uygulamaları ve veri kaynakları.
Önemli yön data → domain'dir. Domain sözleşmeyi tanımlar, data uygular. Domain'in framework bağımsız kalması ve kolay test edilmesi bu sayede olur.
// :core:domain — pure Kotlin. No Android, no Retrofit, no Room.
data class Article(
val id: String,
val title: String,
val publishedAt: Instant,
)
interface ArticleRepository {
fun observeArticles(): Flow<List<Article>>
suspend fun refresh()
}
// :core:data — depends on :core:domain and implements its contract.
internal class DefaultArticleRepository @Inject constructor(
private val remote: ArticleRemoteDataSource,
private val local: ArticleDao,
) : ArticleRepository {
override fun observeArticles(): Flow<List<Article>> =
local.observeAll().map { entities -> entities.map(ArticleEntity::toDomain) }
override suspend fun refresh() {
val network = remote.fetchArticles()
local.upsert(network.map(NetworkArticle::toEntity))
}
}
Feature ViewModel'i yalnız domain'deki ArticleRepository arayüzünü bilir; Retrofit veya Room'u tanımaz. Testte fake repository verilebilir. Use case'leri sade suspend function olarak tasarlamak, scope ve dispatcher yönetimini üst katmanda tutmakla ilgili ayrıntıyı RxJava'dan Coroutines'a geçiş yazısında anlattım.
// :feature:articles — presentation layer, sees only the domain contract.
@HiltViewModel
class ArticlesViewModel @Inject constructor(
repository: ArticleRepository,
) : ViewModel() {
val uiState: StateFlow<ArticlesUiState> =
repository.observeArticles()
.map { articles -> ArticlesUiState.Success(articles.map(Article::toUiModel)) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ArticlesUiState.Loading,
)
}
Convention plugin: build mantığını bir kez yazmak
On modülde on build.gradle.kts dosyası, kontrol edilmezse farklı compileSdk ve unutulan Compose plugin'leri doğurur. Convention plugin, ortak ayarı yeniden kullanılabilir tek yerde toplar.
Google'ın Now in Android örneğindeki build-logic included build yaklaşımı, tek sorumluluklu küçük plugin'ler kullanır. buildSrc değişikliklerinin geniş build etkisini azaltmak için included build tercih edilir; bu tercih bütün değişikliklerin cache etkisiz olduğu anlamına gelmez. Her modül ihtiyaç duyduğu additive plugin'leri seçer.
// build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply("myapp.android.library")
pluginManager.apply("myapp.android.compose")
dependencies {
add("implementation", project(":core:designsystem"))
add("implementation", project(":core:domain"))
}
}
}
Feature build dosyasında yalnız ona özgü kısımlar kalır:
// feature/articles/build.gradle.kts
plugins {
id("myapp.android.feature")
}
android {
namespace = "com.example.feature.articles"
}
Böylece SDK sürümleri, Compose kurulumu ve ortak bağımlılıklar aynı yerden güncellenir. Yalnız bir modüle ait mantığı, herkese uygulanan plugin'e taşımayın.
Yaygın hatalar
Önlemek, sonradan çözmekten daha kolay olan üç durum vardır.
Her şeyi bilen :core
:core, :common veya :utils başlangıçta kolay bir ortak alan olur, sonra her modül ona ve o da çoğuna bağımlı hale gelir. İlgisiz bir helper değişimi bütün uygulamaya yayılır. Başta gerçek sorumluluklardan bölmek daha iyidir: :core:ui, :core:network, :core:common, :core:model. Küçük, odaklı modüller tercih edin.
Döngüsel bağımlılıklar
A, B'ye; B de doğrudan veya dolaylı olarak A'ya bağımlıysa Gradle bunu reddeder. Çözüm çoğu zaman build dosyasında değil tasarımdadır. Ortak parçayı ikisinin de görebildiği modüle çıkarın veya domain/data örneğindeki gibi arayüzle bağımlılığı tersine çevirin.
Data modellerinin UI'ya sızması
@Serializable DTO veya Room @Entity'si kolay diye ViewModel'e ve composable'a taşınırsa UI wire format'a bağlanır. JSON alanı değişince ekran etkilenir. Her katman kendi ihtiyacına uygun model kullanmalı, sınırda dönüşüm yapmalıdır.
// :feature:articles — a UI-shaped model, distinct from the data layer's types.
data class ArticleUiModel(
val id: String,
val title: String,
val relativeTime: String,
)
internal fun Article.toUiModel(): ArticleUiModel =
ArticleUiModel(
id = id,
title = title,
relativeTime = publishedAt.toRelativeTime(),
)
Dönüşüm fonksiyonları başlangıçta tekrar gibi görünür; API değişince etki alanının bir dosyada durmasını sağlar.
Nereden başlamalı?
Tek modüllü uygulamayı bir hafta sonunda yirmi parçaya bölmeyin. Önce birçok yerin kullandığı ve yukarı bağımlı olmayan :core:designsystem ve :core:model ayrılabilir. Sonra iyi anlaşılan bir feature'ı çıkarıp bağımlılık yönünü gözleyin. Aynı build ayarını tekrar eden iki üç modül olduğunda convention plugin ekleyin.
Modül graph'ı yaşayan bir kısıttır. Yanlış yönde kabul edilen her ok, ileride yeniden kurulacak bir sınır olur. Yönü ilk değişiklikte doğru belirlemek en ucuz zamandır.