Every large Android project has an "Architecture Guidelines" page. It says things like "feature modules must not depend on each other" and "the domain layer does not know about Android", and it was last edited about two years ago. Run ./gradlew :app:dependencies on the same project and you will find :feature:search importing BookingRepositoryImpl from :feature:booking. Nobody decided to break the rule. Someone added one implementation(project(...)) line on the last day of a sprint, two reviewers wrote "LGTM", and the build stayed green.
Our position is simple: a dependency rule that cannot fail the build is a wish. In this article we take a hypothetical travel booking app (call it Rota) and move its rules, one by one, into the Gradle classpath, the Kotlin compiler, a graph assertion that runs inside the build, tests, and lint. The iOS counterpart has its own article; this one is Android only.
Written rules rot because breaking them costs nothing
Rota today looks like a pattern we see often in large codebases:
// feature/search/build.gradle.kts (before)
dependencies {
api(project(":core:network")) // "other modules will need it too"
api(project(":core:database"))
implementation(project(":feature:booking")) // the price calculation lived there
implementation(libs.retrofit)
}
// feature/search/src/main/kotlin/com/rota/feature/search/SearchViewModel.kt (before)
import com.rota.core.database.BookingDao
import com.rota.feature.booking.data.BookingRepositoryImpl
class SearchViewModel(
private val bookings: BookingRepositoryImpl, // another feature's implementation
private val dao: BookingDao, // a DAO straight from a ViewModel
) : ViewModel()
This code compiles, passes its tests and ships. No tool says a word to the person who broke the rule. Where a rule lives decides when it speaks up, and to whom:
| Rung | Where the rule lives | When a violation is noticed | How it gets bypassed |
|---|---|---|---|
| Convention | Wiki, onboarding slides | In an architecture meeting, maybe | By not reading it |
| Review | The reviewer's memory | If the reviewer is sharp that day | By opening a 40-file PR |
| Lint | A detekt or Android Lint rule | In the IDE and on CI | With @Suppress or a baseline |
| CI check | An assertion or test in the build | When check runs |
With a visible diff that changes the rule |
| Compiler | Classpath and visibility | While typing, at compile time | By changing the module structure |
The further down the table you go, the more expensive a bypass becomes and the more visible it is. Our goal is to push every rule to the lowest rung it can reach. If the compiler can hold a rule, we do not write a test for it. If a test can catch it, we do not leave it to review.
What is not on the classpath cannot be imported
The strongest rule is one whose violation cannot be expressed. In Gradle the tool for that is the module boundary: you cannot write an import for a class that is not on a module's compile classpath, and the IDE will not even suggest it.
For that to work you have to take api versus implementation seriously. According to the Gradle documentation, a dependency declared with api appears on the compile classpath of consumers, and one declared with implementation does not. The api(project(":core:database")) line above hands the DAOs to everyone who depends on :feature:search. A single api line makes a module boundary porous, and as a side effect more modules recompile when that dependency changes. Our default is implementation. We use api only when the dependency's types really appear in the module's public signatures.
If module boundaries are going to carry the rules, modules need types. This is the structure we are aiming for in Rota:
:app (App) sees everything, only wires things together
:feature:search:api (FeatureApi) navigation contract, the types a feature exposes
:feature:search:impl (Feature) screens and ViewModels; sees only the :api of other features
:core:domain (Domain) pure Kotlin/JVM, use cases and models
:core:data (Data) repository implementations, Room, Retrofit
:core:ui, :core:network (Core) shared infrastructure
Making :core:domain a plain JVM module instead of a com.android.library hands one rule to the compiler all by itself: the Android SDK is not on the classpath, so import android.content.Context does not compile. That is the cheapest and most certain enforcement you will ever get for "domain does not know about Android". We cover the reasoning behind the api/impl split and its effect on the graph in the dependency graph on Android.
Convention plugins: one definition per module type
If thirty build.gradle.kts files contain the same twenty lines, the rule still lives by copy and paste. A convention plugin defines in one place what a module type is allowed to have. The example below assumes the built-in Kotlin support of AGP 9; on AGP 8 you also apply org.jetbrains.kotlin.android.
// 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) {
// shared plugin that sets compileSdk, minSdk, lint and test options
apply(plugin = "rota.android.library")
// graph assertion rules will match on this type, not on the module name
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") // no Android plugin, no android.*
extra["moduleNameAssertAlias"] = "Domain"
extensions.configure<KotlinJvmProjectExtension> {
explicitApi()
}
}
}
}
The plugins are bound to ids such as rota.feature.impl and rota.domain in the gradlePlugin { plugins { register(...) } } block of build-logic/convention/build.gradle.kts. After that, a feature module's build file shrinks to this:
// feature/search/impl/build.gradle.kts (after)
plugins {
id("rota.feature.impl")
}
android {
namespace = "com.rota.feature.search.impl"
}
dependencies {
implementation(project(":feature:search:api"))
implementation(project(":feature:booking:api")) // the contract only
}
A convention plugin does not enforce anything on its own. It gives the module the right defaults, and it does nothing to stop someone from adding implementation(project(":feature:booking:impl")) to that same file. The build itself has to reject that line, and we will get there shortly. First, a look at what escapes from inside a module.
internal and explicitApi(): being public is a decision
Kotlin's default visibility is public. If you draw a module boundary and every class inside is open to the outside by default, the boundary protects the direction of dependencies and leaves the surface unprotected. As long as BookingRepositoryImpl stays public, somebody who can reach it will one day use it directly.
internal makes a declaration visible inside the same module. The Kotlin documentation defines a module as a set of Kotlin files compiled together, and for Gradle it names a source set, noting that the test source set can access the internal declarations of main. In practice, for the main code of a Gradle module, internal means "does not leave this Gradle module". Use it knowing two limits: internal separates nothing between packages of the same module, and internal declarations are public when seen from Java (the compiler only mangles member names). If Java code consumes your module, internal is a suggestion.
The way to flip the default is explicit API mode. In strict mode the compiler requires a visibility modifier and an explicit type on every declaration that would end up in the public API, and reports an error when one is missing. That is why the convention plugins call explicitApi(). In a module script the equivalent is:
kotlin {
explicitApi() // strict: a missing modifier is a compile error
// explicitApiWarning() // for the transition: warnings only
}
With this setting the following code no longer compiles:
// :core:data
class BookingRepositoryImpl(private val dao: BookingDao) : BookingRepository {
override suspend fun upcoming() = dao.upcoming().map { it.toDomain() }
}
The compiler asks you to write a modifier in front of the class. Unless you have a reason to type public, you type internal, and the implementation stays inside the module:
internal class BookingRepositoryImpl(private val dao: BookingDao) : BookingRepository {
override suspend fun upcoming(): List<Booking> = dao.upcoming().map { it.toDomain() }
}
Explicit API mode was designed for library authors, and in a multi-module app every module is a library for the others. Per the documentation the check applies to production sources only, and primary constructors, data class properties and override members are exempt, so it adds no burden to test code. We turn it on for every module type except :app.
Asserting the module graph inside the build
The classpath makes a violation inexpressible, but the build.gradle.kts files still decide which module may put which other module on its classpath, and anyone can edit those files. A module graph assertion closes that gap. The tool we use is com.jraska.module.graph.assertion (2.9.1 at the time of writing). Starting from the module it is applied to, the plugin reads project dependencies in the api and implementation configurations and verifies three kinds of rules: allowed edges, restricted edges and a maximum graph height.
This is where the moduleNameAssertAlias we set in the convention plugins pays off. We express the rules in terms of module types, so nobody has to write regular expressions against individual module names:
// app/build.gradle.kts
plugins {
id("rota.android.application")
id("com.jraska.module.graph.assertion") version "2.9.1"
}
moduleGraphAssert {
// only the edges in this list are legal; any other dependency fails the build
allowed = arrayOf(
"App -> .*",
"Feature -> FeatureApi",
"Feature -> Domain",
"Feature -> Core",
"FeatureApi -> Domain",
"Data -> Domain",
"Data -> Core",
"Core -> Core",
)
maxHeight = 4
}
Each rule has the form regex -> regex and must match the whole dependency pair, so Feature -> FeatureApi does not cover a Feature -> Feature edge. Because the list contains neither Feature -> Feature nor Feature -> Data, the "just one line of :feature:booking:impl" attempt from earlier ends in a VerificationException in the assertModuleGraph task, with a message naming the edge that matched no rule. The absence of any rule for Domain is a rule too: a domain module may not depend on any other module. For transition periods where you cannot write an allow list yet, there are deny rules of the form restricted = arrayOf("Feature -X> Feature").
maxHeight keeps the graph from degenerating into a list. Long chains increase the number of modules recompiled on every change, and they are a sign that layers are leaking. ./gradlew :app:generateModulesGraphStatistics prints your current height and longest path; take the first value from there and lower it over time.
Three details are worth knowing. The task is wired into check, so if CI runs check you need no extra step, and assertOnAnyBuild = true runs the assertions on every build. Only api and implementation are scanned by default, so debugImplementation or flavor-specific configurations have to be added to the configurations setting yourself. The README also warns that Gradle's configuration on demand can hide modules from the plugin; if modules seem to be missing, try --no-configure-on-demand.
Rules inside a module: architecture as tests
Gradle does not see packages. If :feature:booking:impl contains ui, domain and data packages, the classpath cannot stop ui from reaching straight into data. Making every layer its own module is one answer, but not every rule is worth a module. In that gap we write the rules as tests. There are two tools, and they look at different things.
Konsist: rules over Kotlin source
Konsist reads Kotlin source files, which makes rules about visibility modifiers, naming and imports easy to write. We keep the tests in a separate JVM module (:architecture-test), because the rules concern the whole project; the only dependency is testImplementation("com.lemonappdev:konsist:0.17.3").
// 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 `layers inside the booking feature point the right way`() {
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 packages do not import android`() {
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 classes live in a domain package`() {
Konsist.scopeFromProduction()
.classes()
.withNameEndingWith("UseCase")
.assertTrue { it.resideInPackage("..domain..") }
}
@Test
fun `classes ending with Impl do not leave their module`() {
Konsist.scopeFromProduction()
.classes()
.withNameEndingWith("Impl")
.assertTrue { it.hasInternalModifier }
}
}
The package expression in a Layer must end with two dots, which says the package and all of its subpackages belong to the layer. The second test is for domain packages you cannot move into a pure JVM module; if :core:domain already is one, the compiler has been enforcing that rule all along.
One caveat about Konsist: the latest release at the time of writing is 0.17.3, published in December 2024, and it is still pre-1.0. Its documentation says it embeds version 2.0.20 of the Kotlin compiler. Verify on a trial branch that it parses the newer syntax of the Kotlin version you use before you make it your main enforcement tool.
ArchUnit: rules over bytecode
ArchUnit reads compiled classes. It cannot see Kotlin-specific information such as internal, but it does not stop at import lines and looks at the real dependency: a fully qualified usage, or Java code, is caught as well. The project is actively maintained (1.5.1, September 2026). The unit test classpath of :app contains the classes of every module, so that is the easiest place for the test; all it needs is testImplementation("com.tngtech.archunit:archunit:1.5.1").
// 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 does not depend on android or network libraries`() {
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("android..", "androidx..", "retrofit2..", "okhttp3..")
.check(classes)
}
@Test
fun `ui packages do not access data packages directly`() {
noClasses().that().resideInAPackage("..ui..")
.should().dependOnClassesThat().resideInAPackage("..data..")
.check(classes)
}
@Test
fun `feature packages are free of cycles`() {
slices().matching("com.rota.feature.(*)..")
.should().beFreeOfCycles()
.check(classes)
}
}
importPackages may pull in test classes as well. ArchUnit's predefined ImportOption filters expect particular output directory layouts; check in your own project whether they match the directories of Android modules, and write your own ImportOption if they do not.
Our choice between the two: ArchUnit for dependency direction rules, Konsist for rules about Kotlin declarations (naming, modifiers, package placement). If you must pick one tool and most of your rules read "package A must not depend on package B", take ArchUnit. It is maintained, and it has the freeze mechanism you will see below.
The lint level: a red squiggle in the IDE
A test reports a violation when check runs. Lint reports it while the code is being typed, and that shorter feedback loop makes a difference to whether people accept the rule.
detekt's ForbiddenImport rule is off by default and compares import lines against a list of patterns. The rule applies the same list to every file, so we use a config file per module type. In detekt 1.23.8 the option names are:
# config/detekt/domain.yml (added only to modules of type Domain)
style:
ForbiddenImport:
active: true
imports:
- value: 'android.*'
reason: 'The domain layer stays platform independent.'
- value: 'androidx.*'
reason: 'The domain layer stays platform independent.'
- value: 'retrofit2.*'
reason: 'Network types stay in the data layer.'
forbiddenPatterns: 'com\.rota\.core\.database\..*'
// inside DomainConventionPlugin, after the detekt plugin is applied
extensions.configure<DetektExtension> {
buildUponDefaultConfig = true
config.setFrom(
rootProject.files("config/detekt/detekt.yml", "config/detekt/domain.yml"),
)
}
detekt 2.0 is still in alpha, and there the options of this rule become forbiddenImports and allowedImports; you will have to update your config when you upgrade. The rule's limit is clear too: it sees import lines only. A fully qualified call such as android.util.Log.d(...) slips past it. So we treat ForbiddenImport as the fast feedback layer, and the real enforcement stays in the classpath and in tests.
When you need a custom Android Lint rule
Most teams do not. Writing your own lint check pays off when the rule depends on resolved types or call sites, when it involves code together with XML or the manifest, or when you want the violation flagged in Android Studio as it is typed. A small detector that catches a ui package importing a data package of the same module, in the editor, looks like this:
// 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),
"The `ui` package must not access `data` directly, go through a use case",
)
}
}
}
companion object {
@JvmField
val ISSUE: Issue = Issue.create(
id = "UiImportsData",
briefDescription = "UI layer imports the data layer",
explanation = "UI sees the domain layer only; repository and DAO types stay in the data layer.",
category = Category.CORRECTNESS,
priority = 8,
severity = Severity.ERROR,
implementation = Implementation(
UiImportsDataDetector::class.java,
Scope.JAVA_FILE_SCOPE,
),
)
}
}
You register the detector through an IssueRegistry, declare the registry in META-INF/services/com.android.tools.lint.client.api.IssueRegistry, and add lintChecks(project(":lint-checks")) to the convention plugin of your Android modules. The Lint API Guide says plainly that local checks have had problems being rebuilt after changes or clean builds, so go in knowing there is a maintenance cost.
Guarding the dependencies themselves
So far we have talked about rules between modules. Two more tools exist, and each answers a different question.
The Dependency Analysis Gradle Plugin (3.19.2 at the time of writing) compares the dependencies you declare with the ones your code really uses. It reports unused dependencies, dependencies used transitively without being declared, and dependencies sitting in the wrong configuration. The last kind matters for our topic: it tells you to demote to implementation a dependency that is declared as api without appearing in any signature. That puts the "api only when needed" rule under a tool's supervision.
// 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 produces the report and fails the build on findings with severity fail. According to the README, projects that use Kotlin or Android need those plugins loaded in the settings script classloader as well; read the wiki page on setup before you install it.
dependency-guard (0.5.0) writes the resolved dependency list of a configuration to a text file and compares later builds against it. It makes a change in what you ship visible in the PR diff: a version bumped transitively, or a test library leaking into the release classpath.
// app/build.gradle.kts
plugins {
id("com.dropbox.dependency-guard") version "0.5.0"
}
dependencyGuard {
configuration("releaseRuntimeClasspath") {
modules = true // list project modules too
allowedFilter = { !it.contains("junit") && !it.contains("mockk") }
}
}
./gradlew dependencyGuard does the comparison and fails on a difference; when the change is intended, dependencyGuardBaseline rewrites dependencies/releaseRuntimeClasspath.txt and you commit it. With modules = true, wiring a new module into :app also shows up as a line in the diff. Do not confuse the two jobs: Dependency Analysis checks whether the declarations are right, dependency-guard checks whether the result changed.
Rolling this out on an existing codebase
Turn all of these rules on in one day in a project with three hundred violations, and they will all be turned off on day two. The path we follow has the same three steps for every tool: record today's violations, block new ones, burn down the list.
- Measure first. Get the height from
generateModulesGraphStatistics, the number of wrong configurations frombuildHealth, and the number of warnings fromexplicitApiWarning(). Those numbers are your starting point, and later the only honest way to show progress. - Give old modules a temporary type in the graph. modules-graph-assert has no baseline file; the route its README suggests is to give not-yet-migrated modules an alias such as
NeedsMigrationand add"NeedsMigration -> .*"and".* -> NeedsMigration"to theallowedlist. New modules get their real type from day one. With every migrated module those two lines cover less, and in the end you delete them. - Freeze the ArchUnit rules.
FreezingArchRule.freeze(rule)records current violations in a text file and reports only new ones. You set the store location and the permission to create it inarchunit.properties(example below the list). - Konsist has no baseline file (the documentation says one will be added in the future). The recommended route is to narrow the scope: run the test with
Konsist.scopeFromModule(...)on the modules you have fixed, add modules as they are cleaned up, and finish onscopeFromProduction(). - detekt and Android Lint have their own baselines. For detekt, the
detektBaselinetask generates abaseline.xml. For Android Lint, the first run after settinglint { baseline = file("lint-baseline.xml") }creates the file, and subsequent runs report new findings only. - Start with
explicitApiWarning()and move toexplicitApi()module by module. Starting from leaf modules with the fewest dependencies makes the work easier. - In Dependency Analysis, set everything to
warnfirst, apply the suggestions of thefixDependenciestask module by module, then move each cleaned-up category tofail.
The ArchUnit setting from step 3 is this small:
# app/src/test/resources/archunit.properties
freeze.store.default.path=archunit-store
freeze.store.default.allowStoreCreation=true
Commit the store files. After the first commit it makes sense to switch allowStoreCreation off; the ArchUnit documentation also recommends that CI never creates a new store.
Two signs tell you it is working. First, the line counts of the baseline files and the number of NeedsMigration modules go down every sprint; a one-line wc -l on CI is enough to track that. Second, a PR that adds a forbidden dependency is rejected by the build before any person notices it. Try it: on a branch, add :feature:booking:impl to :feature:search:impl and run ./gradlew check. If the build is green, you are not done.
A baseline is an IOU. If the file is growing, the rule is not working.
Where to stop
We do not install all of these tools on every project. In a single-module app with two developers there is no graph for a module graph assertion to protect; a few Konsist or ArchUnit tests and explicitApiWarning() are enough there. Creating modules purely for the sake of enforcement backfires too: every new module adds cost in build configuration, DI wiring and navigation contracts. We did that calculation in the cost of modularization.
Our usual order is: api cleanup and convention plugins first, then the graph assertion, then explicitApi(), and package-level tests and lint last. Do not be afraid of enforcing one rule in two places (the classpath and detekt, say), but writing the same rule in three tools and managing three separate baselines is work nobody keeps up.
Checklist
- Every dependency rule in the wiki has a written answer to "which rung enforces this", and no rule is left whose answer is "review".
- Module types are defined and each has a convention plugin; module build files contain only the lines specific to that module.
apiis used only for dependencies that appear in public signatures; Dependency Analysis fails the build ononIncorrectConfigurationfindings.:core:domainis a pure Kotlin/JVM module with no Android SDK on its classpath.explicitApi()is on in every module except:app;Implclasses areinternal.- A module graph assertion runs on
:app: theallowedlist is written in module types,maxHeightis set, and the task is part ofcheck. - Layer rules inside modules exist as Konsist or ArchUnit tests and run on CI.
- detekt
ForbiddenImportis active in domain modules, and the option names match the detekt version you use. - A dependency-guard baseline for
releaseRuntimeClasspathis committed and CI runsdependencyGuard. - The line count of every baseline (ArchUnit store, detekt, lint,
NeedsMigration) is tracked and going down. - A deliberate violation attempt (
feature -> feature:impl) breaks./gradlew check.
Need support on this?
Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.
Sources
- 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
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.