Architecture dependency rule'larını compiler'a enforce ettirmek: Android

Wiki sayfasında ya da code review yorumunda yaşayan bir dependency rule çoktan delinmiştir. Gradle module sınırları, convention plugin'ler, explicitApi, module graph assertion, Konsist, ArchUnit, detekt ve dependency guard ile kuralları build'i kıran kontrollere çeviriyoruz.

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.

  1. Önce ölçün. generateModulesGraphStatistics ile yüksekliği, buildHealth ile 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.
  2. 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 NeedsMigration gibi bir alias vermek ve allowed listesine "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.
  3. 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 iznini archunit.properties ile verirsiniz (örnek listenin altında).
  4. 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, sonunda scopeFromProduction()'a geçin.
  5. detekt ve Android Lint'in kendi baseline'ları var. detekt için detektBaseline task'ı bir baseline.xml üretir. Android Lint için lint { 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.
  6. explicitApiWarning() ile başlayın, module module explicitApi()'ye geçin. En az dependency'si olan yaprak module'lerden başlamak işi kolaylaştırır.
  7. Dependency Analysis'te önce her şeyi warn yapın, fixDependencies task'ının önerilerini module module uygulayın, temizlenen kategoriyi fail'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.
  • api yalnızca public imzalarda görünen dependency'ler için kullanılıyor; Dependency Analysis onIncorrectConfiguration bulgularında build'i kırıyor.
  • :core:domain saf Kotlin/JVM module'ü; Android SDK classpath'inde yok.
  • :app dışındaki module'lerde explicitApi() açık; Impl sınıfları internal.
  • :app üzerinde module graph assertion çalışıyor: allowed listesi module tipleriyle yazılı, maxHeight tanımlı, task check'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 ForbiddenImport açık; kullandığınız detekt sürümünün ayar adları doğrulandı.
  • releaseRuntimeClasspath için dependency-guard baseline'ı commit'li ve CI dependencyGuard ç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.
Seviye 13 tamamlandıİlerlemen bu tarayıcıda saklanıyor.
Sıradaki: Tuist / SPM / XcodeProj: büyük organizasyonlarda hangisi?

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.