Her büyük Android projesinde bir "Architecture Guidelines" sayfası vardır. İçinde "feature module'leri birbirine bağımlı olamaz", "domain katmanı Android'i bilmez" gibi cümleler yazar, son düzenleme tarihi de genellikle iki yıl öncesidir. Aynı projede ./gradlew :app:dependencies çıktısına baktığınızda :feature:search module'ünün :feature:booking içindeki BookingRepositoryImpl sınıfını import ettiğini görürsünüz. Kimse kuralı çiğnemeye karar vermemiştir. Sprint'in son günü bir satır implementation(project(...)) eklenmiş, review'da iki kişi "LGTM" yazmış, build yeşil kalmıştır.
Bizim bu konudaki tutumumuz basit: build'i kırmayan bir dependency rule bir temennidir. Bu yazıda bir seyahat rezervasyon uygulaması (adına Rota diyelim) üzerinden, kuralları sırayla Gradle classpath'ine, Kotlin compiler'ına, build'in içinde çalışan graph assertion'a, testlere ve lint'e taşıyoruz. iOS tarafındaki karşılığını ayrı bir yazıda anlattık; burada yalnızca Android var.
Yazılı kural çürür, çünkü ihlalin maliyeti yoktur
Rota'nın bugünkü hali büyük projelerde sık gördüğümüz tablodur:
// feature/search/build.gradle.kts (önce)
dependencies {
api(project(":core:network")) // "başka module'lere de lazım olur"
api(project(":core:database"))
implementation(project(":feature:booking")) // fiyat hesabı orada duruyordu
implementation(libs.retrofit)
}
// feature/search/src/main/kotlin/com/rota/feature/search/SearchViewModel.kt (önce)
import com.rota.core.database.BookingDao
import com.rota.feature.booking.data.BookingRepositoryImpl
class SearchViewModel(
private val bookings: BookingRepositoryImpl, // başka feature'ın implementasyonu
private val dao: BookingDao, // ViewModel'den doğrudan DAO
) : ViewModel()
Bu kod derlenir, test edilir, yayına çıkar. Kuralı ihlal eden kişiye hiçbir araç hiçbir şey söylemez. Kuralın nerede yaşadığı, ne zaman ve kime ses çıkaracağını belirler:
| Basamak | Kural nerede yaşar | İhlal ne zaman fark edilir | Nasıl atlatılır |
|---|---|---|---|
| Convention | Wiki, onboarding sunumu | Belki bir mimari toplantısında | Okumayarak |
| Review | Reviewer'ın hafızası | Reviewer o gün dikkatliyse | 40 dosyalık PR açarak |
| Lint | detekt ya da Android Lint kuralı | IDE'de ve CI'da | @Suppress ya da baseline ile |
| CI check | Build içinde assertion ya da test | check task'ı çalışınca |
Kuralı değiştiren, görünür bir diff ile |
| Compiler | Classpath ve visibility | Kod yazılırken, derleme anında | Module yapısını değiştirerek |
Tablonun altına indikçe atlatmanın bedeli artar ve ihlal görünür hale gelir. Hedefimiz her kuralı inebildiği en alt basamağa indirmek. Compiler'a indirilebilen kural için test yazmıyoruz, testle yakalanabilen kuralı review'a bırakmıyoruz.
Classpath'te olmayan şey import edilemez
En güçlü kural, ihlali ifade etmenin mümkün olmadığı kuraldır. Gradle'da bunun aracı module sınırıdır: bir module'ün compile classpath'inde olmayan sınıf için import satırı yazılamaz, IDE onu önermez bile.
Bunun çalışması için api ile implementation farkını ciddiye almak gerekiyor. Gradle dokümantasyonuna göre api ile verilen dependency tüketicilerin compile classpath'ine geçer; implementation ile verilen geçmez. Yukarıdaki api(project(":core:database")) satırı, :feature:search module'üne bağımlı olan herkese DAO'ları da açar. Tek bir api satırı module sınırını geçirgen yapar ve bunun yan etkisi olarak o dependency değiştiğinde yeniden derlenen module sayısı artar. Varsayılanımız implementation; api yalnızca o tip module'ün public imzalarında gerçekten göründüğünde kullanılır.
Module sınırı kuralı taşıyacaksa, module'lerin tipleri olmalı. Rota için hedeflediğimiz yapı:
:app (App) her şeyi görür, yalnızca birleştirir
:feature:search:api (FeatureApi) navigation sözleşmesi, feature'ın dışarıya açtığı tipler
:feature:search:impl (Feature) ekranlar, ViewModel'ler; başka feature'ın yalnızca :api'sini görür
:core:domain (Domain) saf Kotlin/JVM, use case ve model
:core:data (Data) repository implementasyonları, Room, Retrofit
:core:ui, :core:network (Core) ortak altyapı
Burada :core:domain module'ünü com.android.library yerine saf JVM module'ü yapmak tek başına bir kuralı compiler'a devreder: Android SDK classpath'te olmadığı için import android.content.Context derlenmez. "Domain Android'i bilmez" cümlesi için yazılabilecek en ucuz ve en kesin yaptırım budur. Feature'ların api ve impl olarak ayrılmasının gerekçesini ve graph'a etkisini Android'de dependency graph yazısında ayrıntılı ele aldık.
Convention plugin: module tipi başına tek tanım
Otuz module'ün build.gradle.kts dosyasında aynı yirmi satır varsa, kural yine kopyala yapıştırla yaşıyor demektir. Convention plugin, bir module tipinin neye sahip olabileceğini tek yerde tanımlar. Aşağıdaki örnek AGP 9'un built-in Kotlin desteğini varsayıyor; AGP 8 kullanıyorsanız org.jetbrains.kotlin.android plugin'ini ayrıca uygularsınız.
// build-logic/convention/src/main/kotlin/FeatureImplConventionPlugin.kt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.extra
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
class FeatureImplConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// compileSdk, minSdk, lint ve test ayarlarını yapan ortak plugin
apply(plugin = "rota.android.library")
// Graph assertion kuralları module adına değil bu tipe bakacak
extra["moduleNameAssertAlias"] = "Feature"
extensions.configure<KotlinAndroidProjectExtension> {
explicitApi()
}
dependencies {
"implementation"(project(":core:domain"))
"implementation"(project(":core:ui"))
}
}
}
}
// build-logic/convention/src/main/kotlin/DomainConventionPlugin.kt
class DomainConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "org.jetbrains.kotlin.jvm") // Android plugin'i yok, android.* yok
extra["moduleNameAssertAlias"] = "Domain"
extensions.configure<KotlinJvmProjectExtension> {
explicitApi()
}
}
}
}
Plugin'ler build-logic/convention/build.gradle.kts içindeki gradlePlugin { plugins { register(...) } } bloğuyla rota.feature.impl, rota.domain gibi id'lere bağlanır. Bundan sonra bir feature module'ünün build dosyası şuna iner:
// feature/search/impl/build.gradle.kts (sonra)
plugins {
id("rota.feature.impl")
}
android {
namespace = "com.rota.feature.search.impl"
}
dependencies {
implementation(project(":feature:search:api"))
implementation(project(":feature:booking:api")) // yalnızca sözleşme
}
Convention plugin tek başına bir yaptırım sağlamaz. Module'e doğru varsayılanları verir, ama birinin aynı dosyaya implementation(project(":feature:booking:impl")) yazmasını engellemez. O satırı build'in kendisi reddetmeli; buna birazdan geleceğiz. Önce module'ün içinden neyin dışarı çıktığına bakalım.
internal ve explicitApi(): public olmak bir karardır
Kotlin'de varsayılan visibility public'tir. Module sınırını çizdikten sonra içindeki her sınıf varsayılan olarak o sınırın dışına açıksa, sınır yalnızca dependency yönünü korur, yüzeyi korumaz. BookingRepositoryImpl public kaldığı sürece ona erişebilen biri bir gün onu doğrudan kullanır.
internal, bir declaration'ı aynı module içinde görünür kılar. Kotlin dokümantasyonu module'ü "birlikte derlenen Kotlin dosyaları kümesi" olarak tanımlar ve Gradle için bunun bir source set olduğunu, test source set'inin main'in internal declaration'larına erişebildiğini belirtir. Pratikte bir Gradle module'ünün main kodu için internal, "bu Gradle module'ünün dışına çıkmaz" anlamına gelir. İki noktayı bilerek kullanın: aynı module'deki paketler arasında internal hiçbir şey ayırmaz, ve internal declaration'lar Java'dan bakıldığında public görünür (compiler yalnızca üye adlarını mangle eder). Module'ünüzü Java kodu tüketiyorsa internal bir öneriden ibarettir.
Varsayılanı tersine çevirmenin yolu explicit API mode'dur. Strict modda compiler, public API'ye çıkacak her declaration için visibility modifier'ı ve açık tip bildirimi ister; eksikse derleme hatası verir. Convention plugin'de explicitApi() çağrısını bu yüzden yaptık. Module script'inde karşılığı şudur:
kotlin {
explicitApi() // strict: eksik modifier derleme hatası
// explicitApiWarning() // geçiş dönemi için: yalnızca uyarı
}
Bu ayardan sonra aşağıdaki kod derlenmez:
// :core:data
class BookingRepositoryImpl(private val dao: BookingDao) : BookingRepository {
override suspend fun upcoming() = dao.upcoming().map { it.toDomain() }
}
Compiler sınıfın başına bir modifier yazmanızı ister. public yazmak için bir gerekçeniz yoksa internal yazarsınız ve implementasyon module'ün içinde kalır:
internal class BookingRepositoryImpl(private val dao: BookingDao) : BookingRepository {
override suspend fun upcoming(): List<Booking> = dao.upcoming().map { it.toDomain() }
}
Explicit API mode kütüphane yazarları için tasarlandı, ama çok module'lü bir uygulamada her module diğerlerinin kütüphanesidir. Dokümantasyona göre kontrol yalnızca production kaynaklarına uygulanır; primary constructor'lar, data class property'leri ve override üyeler kapsam dışındadır, yani test koduna ek yük getirmez. Biz :app dışındaki bütün module tiplerinde açıyoruz.
Module graph'ı build'in içinde doğrulamak
Classpath ihlali ifade edilemez kılar, ama hangi module'ün hangisini classpath'e alabileceğini hâlâ build.gradle.kts dosyaları belirliyor ve o dosyaları herkes düzenleyebiliyor. Bu boşluğu module graph assertion kapatır. Kullandığımız araç com.jraska.module.graph.assertion (yazı tarihinde 2.9.1). Plugin, uygulandığı module'den başlayarak api ve implementation configuration'larındaki project dependency'lerini okur ve üç tür kural doğrular: izin verilen kenarlar, yasaklanan kenarlar ve graph'ın azami yüksekliği.
Convention plugin'lerde her module'e verdiğimiz moduleNameAssertAlias burada işe yarıyor. Kuralları module tipleriyle ifade ediyoruz; module adlarına tek tek regex yazmak gerekmiyor:
// app/build.gradle.kts
plugins {
id("rota.android.application")
id("com.jraska.module.graph.assertion") version "2.9.1"
}
moduleGraphAssert {
// Yalnızca bu listedeki kenarlar yasal; eşleşmeyen her dependency build'i kırar
allowed = arrayOf(
"App -> .*",
"Feature -> FeatureApi",
"Feature -> Domain",
"Feature -> Core",
"FeatureApi -> Domain",
"Data -> Domain",
"Data -> Core",
"Core -> Core",
)
maxHeight = 4
}
Her kural regex -> regex biçimindedir ve dependency çiftinin tamamıyla eşleşmesi gerekir; yani Feature -> FeatureApi kuralı Feature -> Feature kenarını kapsamaz. Listede Feature -> Feature ve Feature -> Data olmadığı için, yukarıdaki "bir satır :feature:booking:impl" denemesi assertModuleGraph task'ında VerificationException ile biter ve hata mesajı hangi kenarın hiçbir kurala uymadığını yazar. Domain için hiçbir kural olmaması da bir kuraldır: domain module'ü başka hiçbir module'e bağımlı olamaz. Whitelist yazamayacağınız geçiş dönemleri için restricted = arrayOf("Feature -X> Feature") biçiminde kara liste kuralları da var.
maxHeight, graph'ın bir listeye dönüşmesini engeller. Uzun zincirler hem her değişiklikte yeniden derlenen module sayısını artırır hem de katmanların sızdığının işaretidir. Projenizin bugünkü yüksekliğini ve en uzun yolu ./gradlew :app:generateModulesGraphStatistics ile görebilirsiniz; ilk değeri oradan alıp zamanla düşürün.
Bilmeniz gereken üç ayrıntı var. Task check'e bağlıdır, yani CI'da check çalışıyorsa ek adım gerekmez; assertOnAnyBuild = true ile her build'de çalıştırmak da mümkündür. Varsayılan olarak yalnızca api ve implementation taranır, debugImplementation ya da flavor'a özel configuration'ları configurations ayarına sizin eklemeniz gerekir. README ayrıca Gradle'ın configuration on demand özelliğinin bazı module'leri plugin'den gizleyebileceğini söylüyor; eksik module görürseniz --no-configure-on-demand ile deneyin.
Module'ün içindeki kurallar: test olarak architecture
Gradle paketleri görmez. :feature:booking:impl içinde ui, domain ve data paketleri varsa, ui'ın data'ya doğrudan gitmesini classpath engelleyemez. Her katmanı ayrı module yapmak bir çözümdür ama her kural bir module açmaya değmez. Bu boşlukta kuralları test olarak yazıyoruz. İki araç var ve farklı şeylere bakıyorlar.
Konsist: Kotlin kaynağına bakan kurallar
Konsist Kotlin kaynak dosyalarını okur; bu yüzden visibility modifier'ları, isimlendirme ve import'lar üzerinde kural yazmak kolaydır. Testleri ayrı bir JVM module'üne (:architecture-test) koyuyoruz, çünkü kurallar projenin tamamını ilgilendiriyor; dependency olarak testImplementation("com.lemonappdev:konsist:0.17.3") yeterli.
// architecture-test/src/test/kotlin/ArchitectureKonsistTest.kt
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.architecture.KoArchitectureCreator.assertArchitecture
import com.lemonappdev.konsist.api.architecture.Layer
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
import com.lemonappdev.konsist.api.verify.assertFalse
import com.lemonappdev.konsist.api.verify.assertTrue
import org.junit.jupiter.api.Test
class ArchitectureKonsistTest {
@Test
fun `booking feature icinde katmanlar dogru yone bakar`() {
Konsist.scopeFromProduction().assertArchitecture {
val ui = Layer("UI", "com.rota.feature.booking.impl.ui..")
val domain = Layer("Domain", "com.rota.feature.booking.impl.domain..")
val data = Layer("Data", "com.rota.feature.booking.impl.data..")
domain.dependsOnNothing()
ui.dependsOn(domain)
data.dependsOn(domain)
}
}
@Test
fun `domain paketleri android import etmez`() {
Konsist.scopeFromProduction()
.files
.filter { it.packagee?.name?.contains(".domain") == true }
.assertFalse { file ->
file.hasImport { it.name.startsWith("android.") || it.name.startsWith("androidx.") }
}
}
@Test
fun `UseCase siniflari domain paketinde durur`() {
Konsist.scopeFromProduction()
.classes()
.withNameEndingWith("UseCase")
.assertTrue { it.resideInPackage("..domain..") }
}
@Test
fun `Impl ile biten siniflar module disina cikmaz`() {
Konsist.scopeFromProduction()
.classes()
.withNameEndingWith("Impl")
.assertTrue { it.hasInternalModifier }
}
}
Layer tanımındaki paket ifadesi iki nokta ile bitmek zorundadır; bu, paketin alt paketleriyle birlikte katmana dahil olduğunu söyler. İkinci test, saf JVM module'üne taşıyamadığınız domain paketleri içindir; :core:domain zaten JVM module'üyse o kuralı compiler çoktan uyguluyor.
Konsist hakkında bir uyarı: yazı tarihindeki son sürüm 0.17.3, Aralık 2024'te yayınlandı ve hâlâ 1.0 öncesi. Dokümantasyonuna göre Kotlin compiler'ının 2.0.20 sürümünü gömülü kullanıyor. Kendi projenizde kullandığınız Kotlin sürümünün yeni sözdizimini parse edip etmediğini bir deneme branch'inde doğrulamadan ana yaptırım aracınız yapmayın.
ArchUnit: bytecode'a bakan kurallar
ArchUnit derlenmiş sınıfları okur. Kotlin'e özgü bilgiyi (örneğin internal) görmez, ama import satırıyla yetinmez, gerçek bağımlılığa bakar: tam nitelikli adla yazılmış bir kullanım ya da Java kodu da yakalanır. Proje aktif olarak sürdürülüyor (1.5.1, Eylül 2026). :app module'ünün unit test classpath'inde bütün module'lerin sınıfları bulunduğu için testi oraya koymak en kolay yoldur; gereken tek şey testImplementation("com.tngtech.archunit:archunit:1.5.1") satırıdır.
// app/src/test/kotlin/com/rota/DependencyRulesTest.kt
import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices
import org.junit.Test
class DependencyRulesTest {
private val classes = ClassFileImporter().importPackages("com.rota")
@Test
fun `domain android ve network kutuphanelerine bagimli degil`() {
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("android..", "androidx..", "retrofit2..", "okhttp3..")
.check(classes)
}
@Test
fun `ui paketleri data paketlerine dogrudan erismez`() {
noClasses().that().resideInAPackage("..ui..")
.should().dependOnClassesThat().resideInAPackage("..data..")
.check(classes)
}
@Test
fun `feature paketleri arasinda dongu yok`() {
slices().matching("com.rota.feature.(*)..")
.should().beFreeOfCycles()
.check(classes)
}
}
importPackages test sınıflarını da içeri alabilir. ArchUnit'in hazır ImportOption filtreleri belirli çıktı dizini düzenlerini bekler; Android module'lerinin dizinleriyle uyuşup uyuşmadığını kendi projenizde kontrol edin, gerekirse kendi ImportOption'ınızı yazın.
İkisi arasında seçimimiz şu: bağımlılık yönü kuralları için ArchUnit, Kotlin declaration'larına dair kurallar (isimlendirme, modifier, paket yerleşimi) için Konsist. Tek araç seçmek zorundaysanız ve kurallarınızın çoğu "A paketi B paketine bağımlı olamaz" türündeyse ArchUnit'i alın; bakımı sürüyor ve aşağıda göreceğiniz freeze mekanizması var.
Lint seviyesi: IDE'de kırmızı çizgi
Test, ihlali check çalışınca söyler. Lint aynı şeyi kod yazılırken söyler ve bu geri bildirim süresi, kuralın benimsenmesinde fark yaratır.
detekt'in ForbiddenImport kuralı varsayılan olarak kapalıdır ve import satırlarını bir desen listesiyle karşılaştırır. Kural bütün dosyalara aynı listeyi uyguladığı için module tipine özel config dosyası kullanıyoruz. detekt 1.23.8'de ayar adları şöyledir:
# config/detekt/domain.yml (yalnızca Domain tipindeki module'lere eklenir)
style:
ForbiddenImport:
active: true
imports:
- value: 'android.*'
reason: 'Domain katmanı platformdan bağımsız kalır.'
- value: 'androidx.*'
reason: 'Domain katmanı platformdan bağımsız kalır.'
- value: 'retrofit2.*'
reason: 'Network tipleri data katmanında kalır.'
forbiddenPatterns: 'com\.rota\.core\.database\..*'
// DomainConventionPlugin içinde, detekt plugin'i uygulandıktan sonra
extensions.configure<DetektExtension> {
buildUponDefaultConfig = true
config.setFrom(
rootProject.files("config/detekt/detekt.yml", "config/detekt/domain.yml"),
)
}
detekt 2.0 hâlâ alpha aşamasında ve bu kuralın ayarları orada forbiddenImports ve allowedImports olarak değişiyor; yükseltirken config'inizi güncellemeniz gerekecek. Kuralın sınırı da belli: yalnızca import satırlarını görür. android.util.Log.d(...) gibi tam nitelikli bir çağrı gözünden kaçar. Bu yüzden ForbiddenImport'u hızlı geri bildirim katmanı olarak görüyoruz, asıl yaptırım classpath'te ve testte kalıyor.
Custom Android Lint kuralı ne zaman gerekir
Çoğu ekip için gerekmez. Kendi lint kuralınızı yazmanın karşılığını aldığınız durumlar şunlardır: kural çözümlenmiş tiplere ya da çağrı yerine bağlıysa, kod ile XML ya da manifest'i birlikte ilgilendiriyorsa, ya da ihlalin Android Studio'da yazılırken işaretlenmesini istiyorsanız. Aynı module içindeki ui paketinin data paketini import etmesini editörde yakalayan küçük bir detector şöyle görünür:
// lint-checks/src/main/kotlin/com/rota/lint/UiImportsDataDetector.kt
class UiImportsDataDetector : Detector(), SourceCodeScanner {
override fun getApplicableUastTypes(): List<Class<out UElement>> =
listOf(UImportStatement::class.java)
override fun createUastHandler(context: JavaContext): UElementHandler =
object : UElementHandler() {
override fun visitImportStatement(node: UImportStatement) {
val filePackage = context.uastFile?.packageName ?: return
if (!filePackage.contains(".ui")) return
val imported = node.importReference?.asSourceString() ?: return
if (imported.contains(".data.")) {
context.report(
ISSUE, node, context.getLocation(node),
"`ui` paketi `data` paketine doğrudan erişemez, use case üzerinden gidin",
)
}
}
}
companion object {
@JvmField
val ISSUE: Issue = Issue.create(
id = "UiImportsData",
briefDescription = "UI katmanı data katmanını import ediyor",
explanation = "UI yalnızca domain katmanını görür; repository ve DAO tipleri data katmanında kalır.",
category = Category.CORRECTNESS,
priority = 8,
severity = Severity.ERROR,
implementation = Implementation(
UiImportsDataDetector::class.java,
Scope.JAVA_FILE_SCOPE,
),
)
}
}
Detector'ı bir IssueRegistry ile kaydeder, registry'yi META-INF/services/com.android.tools.lint.client.api.IssueRegistry dosyasında bildirir ve Android module'lerinin convention plugin'ine lintChecks(project(":lint-checks")) eklersiniz. Lint API Guide, yerel check'lerin clean build sonrasında yeniden derlenmesiyle ilgili sorunlar yaşandığını açıkça yazıyor; bir bakım maliyeti olduğunu bilerek girin.
Dependency'lerin kendisini korumak
Buraya kadar module'ler arası kuralları konuştuk. İki araç daha var ve ikisi de farklı bir soruya cevap veriyor.
Dependency Analysis Gradle Plugin (yazı tarihinde 3.19.2), beyan ettiğiniz dependency'lerle kodun gerçekten kullandıklarını karşılaştırır. Kullanılmayan dependency'leri, transitive olarak kullanılıp doğrudan beyan edilmemiş olanları ve yanlış configuration'da duranları raporlar. Sonuncusu bizim konumuz için değerli: imzalarda görünmediği halde api ile verilmiş bir dependency'yi implementation'a indirmenizi söyler. Böylece "api yalnızca gerektiğinde" kuralı bir araç tarafından denetlenir.
// settings.gradle.kts
plugins {
id("com.autonomousapps.build-health") version "3.19.2"
}
// build.gradle.kts (root)
dependencyAnalysis {
issues {
all {
onIncorrectConfiguration { severity("fail") }
onUsedTransitiveDependencies { severity("fail") }
onUnusedDependencies { severity("warn") }
}
}
}
./gradlew buildHealth raporu üretir ve fail seviyesindeki bulgularda build'i kırar. README'ye göre Kotlin ya da Android kullanan projelerde bu plugin'lerin settings script classloader'ında da yüklü olması gerekiyor; kurulumdan önce wiki'deki ilgili sayfayı okuyun.
dependency-guard (0.5.0) ise bir configuration'ın çözümlenmiş dependency listesini bir metin dosyasına yazar ve sonraki build'lerde bu dosyayla karşılaştırır. Yayına giden şeyin değiştiğini, yani transitive olarak yükselen bir sürümü ya da release classpath'ine sızan bir test kütüphanesini PR diff'inde görünür kılar.
// app/build.gradle.kts
plugins {
id("com.dropbox.dependency-guard") version "0.5.0"
}
dependencyGuard {
configuration("releaseRuntimeClasspath") {
modules = true // project module'lerini de listeye yaz
allowedFilter = { !it.contains("junit") && !it.contains("mockk") }
}
}
./gradlew dependencyGuard karşılaştırır, fark varsa hata verir; değişiklik bilinçliyse dependencyGuardBaseline ile dependencies/releaseRuntimeClasspath.txt dosyasını güncelleyip commit'lersiniz. modules = true ile :app'e yeni bir module bağlandığında da diff'te bir satır belirir. İki aracın işi karıştırılmasın: Dependency Analysis beyanların doğru olup olmadığına bakar, dependency-guard sonucun değişip değişmediğine.
Mevcut bir projede devreye almak
Bu kuralların tamamını üç yüz ihlali olan bir projede bir günde açarsanız, ikinci gün hepsi kapatılır. İzlediğimiz yol her araçta aynı üç adımdan oluşuyor: bugünkü ihlalleri kayda al, yenisini engelle, listeyi erit.
- Önce ölçün.
generateModulesGraphStatisticsile yüksekliği,buildHealthile yanlış configuration sayısını,explicitApiWarning()ile uyarı sayısını alın. Bu sayılar başlangıç noktanızdır ve sonradan ilerlemeyi göstermenin tek dürüst yoludur. - Graph için eski module'lere geçici bir tip verin. modules-graph-assert'te baseline dosyası yok; README'nin önerdiği yol, henüz taşınmamış module'lere
NeedsMigrationgibi bir alias vermek veallowedlistesine"NeedsMigration -> .*"ile".* -> NeedsMigration"eklemektir. Yeni module'ler ilk günden gerçek tiplerini alır. Her taşınan module ile bu iki satırın kapsadığı alan daralır, sonunda silinir. - ArchUnit kurallarını dondurun.
FreezingArchRule.freeze(rule)mevcut ihlalleri bir metin dosyasına kaydeder ve yalnızca yenilerini raporlar. Store'un yerini ve oluşturulma izniniarchunit.propertiesile verirsiniz (örnek listenin altında). - Konsist'te baseline dosyası yok (dokümantasyon ileride ekleneceğini söylüyor). Önerilen yol scope'u daraltmaktır: testi önce
Konsist.scopeFromModule(...)ile düzeltilmiş module'lerde çalıştırın, temizlendikçe module ekleyin, sonundascopeFromProduction()'a geçin. - detekt ve Android Lint'in kendi baseline'ları var. detekt için
detektBaselinetask'ı birbaseline.xmlüretir. Android Lint içinlint { baseline = file("lint-baseline.xml") }ayarından sonraki ilk çalıştırma dosyayı oluşturur ve sonraki çalıştırmalar yalnızca yeni bulguları raporlar. explicitApiWarning()ile başlayın, module moduleexplicitApi()'ye geçin. En az dependency'si olan yaprak module'lerden başlamak işi kolaylaştırır.- Dependency Analysis'te önce her şeyi
warnyapın,fixDependenciestask'ının önerilerini module module uygulayın, temizlenen kategoriyifail'e çekin.
Üçüncü adımdaki ArchUnit ayarı şu kadar:
# app/src/test/resources/archunit.properties
freeze.store.default.path=archunit-store
freeze.store.default.allowStoreCreation=true
Store dosyalarını commit'leyin. İlk commit'ten sonra allowStoreCreation değerini kapatmak mantıklıdır; ArchUnit dokümantasyonu da CI'ın yeni bir store oluşturmamasını öneriyor.
İşe yaradığını iki işaretten anlarsınız. Birincisi, baseline dosyalarının satır sayısı ve NeedsMigration module sayısı her sprint azalıyor; bunu CI'da tek satır wc -l ile izleyebilirsiniz. İkincisi, yasak bir dependency ekleyen PR'ı kimse fark etmeden önce build reddediyor. Deneyin: bir branch'te :feature:search:impl'e :feature:booking:impl ekleyip ./gradlew check çalıştırın. Build yeşilse işiniz bitmemiştir.
Baseline bir borç senedidir. Dosya büyüyorsa kural çalışmıyordur.
Nerede durmalı
Bu araçların hepsini her projeye kurmuyoruz. Tek module'lü, iki kişilik bir uygulamada module graph assertion'ın koruyacağı bir graph yoktur; orada birkaç Konsist ya da ArchUnit testi ve explicitApiWarning() yeterlidir. Sırf yaptırım için module açmak da ters teper: her yeni module build konfigürasyonuna, DI bağlantısına ve navigation sözleşmesine maliyet ekler. Bu hesabı modularization'ın maliyeti yazısında yaptık.
Sıralamamız genellikle şöyle: önce api temizliği ve convention plugin'ler, sonra graph assertion, sonra explicitApi(), en son paket seviyesindeki testler ve lint. Bir kuralı iki yerde uygulamaktan çekinmeyin (classpath ve detekt gibi), ama aynı kuralı üç araca yazıp üçünün de baseline'ını ayrı ayrı yönetmek kimsenin sürdüremeyeceği bir iştir.
Kontrol listesi
- Wiki'deki her dependency rule için "bunu hangi basamak uyguluyor" sorusunun cevabı yazılı; cevabı "review" olan kural kalmadı.
- Module tipleri tanımlı ve her tip için bir convention plugin var; module build dosyalarında yalnızca o module'e özel satırlar duruyor.
apiyalnızca public imzalarda görünen dependency'ler için kullanılıyor; Dependency AnalysisonIncorrectConfigurationbulgularında build'i kırıyor.:core:domainsaf Kotlin/JVM module'ü; Android SDK classpath'inde yok.:appdışındaki module'lerdeexplicitApi()açık;Implsınıflarıinternal.:appüzerinde module graph assertion çalışıyor:allowedlistesi module tipleriyle yazılı,maxHeighttanımlı, taskcheck'e bağlı.- Module içi katman kuralları Konsist ya da ArchUnit testi olarak yazılı ve CI'da çalışıyor.
- Domain module'lerinde detekt
ForbiddenImportaçık; kullandığınız detekt sürümünün ayar adları doğrulandı. releaseRuntimeClasspathiçin dependency-guard baseline'ı commit'li ve CIdependencyGuardçalıştırıyor.- Her baseline (ArchUnit store, detekt, lint,
NeedsMigration) için satır sayısı izleniyor ve azalıyor. - Bilerek yapılan bir ihlal denemesi (
feature -> feature:impl)./gradlew check'i kırıyor.
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
- Gradle · The Java Library Plugin
- Kotlin · Visibility modifiers
- Kotlin · What's new in Kotlin 1.4 (Explicit API mode)
- GitHub · jraska/modules-graph-assert
- Konsist · Architecture assertion
- ArchUnit · User Guide
- detekt · Style rule set 1.23.8 (ForbiddenImport)
- GitHub · dropbox/dependency-guard
- GitHub · autonomousapps/dependency-analysis-gradle-plugin
- Android · Lint API Guide
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.