The module boundary that only the build file enforced
A layering rule nobody can violate is a dependency declaration, not a document.
TL;DR
- A documented layering rule — features never import each other — was violated 31 times across 17 modules, and every one of those imports compiled.
- The rule was in a markdown file and in two years of code-review comments. Neither of those is a mechanism, so the count only existed once something counted.
- Declaring the boundary in Gradle made 31 of them compile errors and the remaining work visible: 9 were genuinely shared concepts in the wrong module, and 22 were a feature reaching for another feature's ViewModel.
- What reading on buys: the dependency declaration that enforces it, the one escape hatch that
keeps it usable, and why
apiversusimplementationis the part people get wrong.
The case this post is about
A Kotlin Multiplatform app with 17 feature modules under feature/ and a documented rule:
# docs/ARCHITECTURE.md
Features MUST NOT depend on other features. Shared concepts live in core/.
The module that broke it:
// feature/checkout/src/commonMain/kotlin/CheckoutViewModel.kt
import com.example.feature.cart.CartViewModel // ← another feature
import com.example.feature.cart.CartItem
class CheckoutViewModel(private val cart: CartViewModel) : ViewModel() {
fun lines(): List<CartItem> = cart.state.value.items
}
That import is well-formed Kotlin, it resolves, it compiles, and ./gradlew build is green —
because feature/checkout/build.gradle.kts had this in it:
commonMain.dependencies {
implementation(projects.core.data)
implementation(projects.feature.cart) // ← added in one PR, 14 months ago
}
The rule said no. The build file said yes. The build file wins, every time, on every machine.
The symptom
Not a crash — a 40-minute incremental build and a feature team that could not ship without another feature team's review.
The build time was what surfaced it. A one-line change in feature/cart recompiled nine modules,
and nobody could say why nine:
$ ./gradlew :feature:cart:assemble --dry-run | grep -c 'compileKotlin'
9
Then the second symptom, which is the expensive one. A change to CartViewModel's state shape
broke feature/checkout — a module in a different team's area, reviewed by people who had never
read the cart code:
e: Unresolved reference: items
feature/checkout/src/commonMain/kotlin/CheckoutViewModel.kt:14
A refactor inside one feature became a cross-team PR. That is the cost the rule existed to prevent, and the rule had been in place the whole time.

Why the obvious fix failed
The obvious fix is to find the imports and delete them. I wrote the grep:
$ grep -rn 'import com.example.feature\.' --include='*.kt' feature/ \
| grep -v "feature/$(basename $(dirname $PWD))" | wc -l
31
Thirty-one, across 17 modules. And the grep is wrong in both directions, which is why the number could not be trusted:
It misses the real ones. A fully-qualified reference needs no import:
val vm = com.example.feature.cart.CartViewModel(...) // invisible to the grep
And it flags things that are fine. feature/checkout importing feature.checkout.internal is
its own package, and the path-exclusion above only works when the module directory name matches
the package segment. Ours did not, in four cases.
So the grep produced a number that was neither a floor nor a ceiling. Worse, deleting imports does not stop the next one: the dependency stays in the build file, so the next author's IDE offers the completion, accepts it, and compiles. A review habit is the only thing standing between the rule and the code, and a review habit is a person remembering.
The actual mechanism
A module boundary is a property of the dependency graph, and the graph is declared in exactly one place. Three consequences follow, and only the first is obvious:
The compiler enforces what the build file declares, and nothing else. ARCHITECTURE.md is not
an input to compilation. If feature/checkout declares feature/cart, every symbol in cart's
public API is a legal reference — and the IDE will suggest them, which means the violation is the
path of least resistance rather than a deliberate act.
api versus implementation decides whether the violation is transitive. This is the part
that turned 31 imports into 9 recompiled modules. core/data declared api(projects.core.model),
so every module depending on core/data also saw core/model — and two feature modules had
declared api(projects.feature.cart), which handed cart's whole surface to their consumers.
A boundary violated with api propagates; the same violation with implementation stops at one
module.
And a cycle is prevented by Gradle, which is why this shape survives. Gradle refuses a circular
project dependency outright, so the mesh that accumulates is always a DAG — checkout → cart, and
later cart → profile, and profile → checkout would be the error that finally stopped it. Until
that third edge, every addition compiles, and nothing reports that the graph has stopped being a
layering.
So the rule was never enforced by anything. It was correlated with compliance for as long as nobody needed a shortcut, and 14 months ago somebody needed one.

The fix
Declare the boundary as a dependency rule in the convention plugin that every feature module already applies, so a violating module cannot be configured — not merely cannot be merged.
// build-logic/src/main/kotlin/kmp-feature-module.gradle.kts
//
// Applied by every feature/* module. The rule it enforces is the one ARCHITECTURE.md states,
// expressed where the compiler can see it. A violation fails CONFIGURATION, which is earlier
// and louder than a compile error and cannot be worked around by removing an import.
plugins { id("kmp-library") }
/** Features a module may depend on despite the rule, with the reason, from its own build file. */
val sanctionedFeatureDeps: SetProperty<String> =
objects.setProperty<String>().convention(emptySet())
extensions.add("sanctionedFeatureDeps", sanctionedFeatureDeps)
afterEvaluate {
val selfPath = project.path // e.g. :feature:checkout
val allowed = sanctionedFeatureDeps.get()
// Every resolvable configuration, not just the common one: a dependency declared in
// androidMain alone is the same violation and would otherwise pass this check.
val violations = configurations
.filter { it.isCanBeResolved || it.dependencies.isNotEmpty() }
.flatMap { cfg ->
cfg.dependencies
.filterIsInstance<ProjectDependency>()
.map { cfg.name to it.dependencyProject.path }
}
.filter { (_, dep) -> dep.startsWith(":feature:") && dep != selfPath }
.filterNot { (_, dep) -> dep in allowed }
if (violations.isNotEmpty()) {
// Name the FIX, not just the violation: a message that only says "not allowed"
// produces a workaround attempt, and the workaround here is a fully-qualified
// reference that no grep finds.
error(buildString {
appendLine("$selfPath declares ${violations.size} feature-to-feature dependency(ies):")
violations.forEach { (cfg, dep) -> appendLine(" $cfg → $dep") }
appendLine()
appendLine("Features must not depend on features (docs/ARCHITECTURE.md).")
appendLine("Move the shared type DOWN into core/, or — if this is genuinely")
appendLine("a feature composing another feature — declare it with a reason:")
appendLine()
appendLine(" sanctionedFeatureDeps.add(\"${violations.first().second}\") // why")
})
}
// api() on a feature dependency hands that feature's surface to every consumer, which is
// how one violation became nine recompiled modules. Sanctioned or not, it stays banned.
val leaked = configurations.filter { it.name.endsWith("Api") }
.flatMap { c -> c.dependencies.filterIsInstance<ProjectDependency>().map { it.dependencyProject.path } }
.filter { it.startsWith(":feature:") && it != selfPath }
if (leaked.isNotEmpty()) {
error("$selfPath exposes ${leaked.joinToString()} via api() — use implementation(), " +
"even when the dependency is sanctioned. api() makes the boundary transitive.")
}
}
And the output it produced on the named case, the first time it ran:
$ ./gradlew :feature:checkout:assemble
FAILURE: Build failed with an exception.
* Where: Build file 'feature/checkout/build.gradle.kts'
:feature:checkout declares 1 feature-to-feature dependency(ies):
commonMainImplementation → :feature:cart
Features must not depend on features (docs/ARCHITECTURE.md).
Move the shared type DOWN into core/, or — if this is genuinely
a feature composing another feature — declare it with a reason:
sanctionedFeatureDeps.add(":feature:cart") // why
The repair for this one was the first option. CartItem was never a cart concept — it is an order
line, used by checkout, cart and order history — so it moved to core/model, and checkout stopped
needing cart at all:
// feature/checkout/build.gradle.kts — after
commonMain.dependencies {
implementation(projects.core.data)
implementation(projects.core.model) // CartItem lives here now
}
The sanctionedFeatureDeps hatch is the part I would defend hardest. Two of the 31 were real —
a shell feature that composes the others by design, and an onboarding flow that genuinely drives
the profile feature — and a rule with no legal exception is a rule somebody deletes. The hatch
costs a line in the violating module's own build file, which puts the exception where the next
reader of that module will see it rather than in a central allowlist nobody opens.
How we proved it
That the rule had never been enforced, which the grep could not establish:
$ ./gradlew allFeatureModules:assemble 2>&1 | grep -c 'feature-to-feature dependency'
31
Thirty-one, from the dependency graph rather than from text — and the two numbers agreeing was luck. The graph found 4 the grep missed (fully-qualified references with no import) and rejected 4 the grep had flagged (same-package imports), so the overlap was 27 of 31 either way.
That the 9-module recompile was the api leak and not the dependency itself. Changing two
api(projects.feature.cart) declarations to implementation and re-measuring, with nothing else
touched:
$ ./gradlew :feature:cart:assemble --dry-run | grep -c 'compileKotlin'
9 # before
4 # after — api → implementation, no other change
Five of the nine were transitive consumers that had never referenced cart.
That the configuration-time failure cannot be worked around by deleting an import. The test is the thing the grep-and-delete approach would have produced — remove the import, keep the dependency, use a fully-qualified name — and confirm the gate still fails:
// the deliberately-broken fixture
val vm = com.example.feature.cart.CartViewModel(repo) // no import at all
$ ./gradlew :fixtures:fq-reference:assemble
:fixtures:fq-reference declares 1 feature-to-feature dependency(ies):
commonMainImplementation → :feature:cart
Still fails, because the check reads the graph and not the source. That is the property that made it worth building: the previous enforcement mechanism was a reviewer noticing a line, and a fully-qualified reference is exactly the line a reviewer does not notice.
And the honest remainder. Of the 31, nine were shared concepts that moved down to core/, 20
were a feature reaching for another feature's ViewModel and were rewritten to go through a shared
repository, and two are sanctioned with a reason. Those two are not resolved — they are
declared. A shell that composes features is a real architectural shape, and pretending the rule
covers it would have meant either a false exception or a worse design.
What I cannot tell you is whether the build-time improvement holds at a larger module count. 17 features is a mid-sized graph, and the 40-minute incremental build fell to 11 after the sweep — measured on one machine, one branch, with a warm daemon, so treat the ratio as this codebase's rather than a general figure.

What this generalizes to
A rule that the toolchain cannot evaluate is a convention, and conventions decay at exactly the rate that shortcuts become useful. The useful question about any architectural rule is not whether the team agrees with it but what would have to fail if somebody violated it — and if the honest answer is "a reviewer would notice", the rule is already a suggestion. Expressing it where the build system can see it costs a convention plugin and turns the violation from a merge-time argument into a configuration error, which is both earlier and impersonal. The escape hatch is not a weakening of that; it is what keeps the rule from being deleted the first time somebody has a legitimate reason.
Related
- Two feature modules that needed each other, and the type that fixed it — the nine shared concepts from this post, and how to tell a real one from a shortcut.
- A klib is not a jar, and that changes how dependencies resolve — the other half of reading your dependency graph: which variant each target actually selected.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".