Skip to content

Enforce module boundaries from the build - #78

Merged
AhmadKharfan merged 1 commit into
developfrom
feat/module-boundary-enforcement
Jul 29, 2026
Merged

Enforce module boundaries from the build#78
AhmadKharfan merged 1 commit into
developfrom
feat/module-boundary-enforcement

Conversation

@AhmadKharfan

Copy link
Copy Markdown
Owner

Why

The previous two changes removed 14 stale module dependencies and routed the remaining three through :domain. Nothing stops all of that from growing back — the layering has never been checked by anything, so a single implementation(projects.data.local) added in review would silently undo it.

Both reference projects this architecture is modelled on have exactly this gap: the api/impl split they are built around is held up by convention alone, and one of them has already drifted. This adds the missing mechanism while the graph is clean, so the baseline records real coupling rather than accumulated noise.

What changed

  • New asl.module.boundaries plugin on the root project, contributing a verifyModuleBoundaries task that fails the build on a forbidden module dependency.
  • Rules: a feature may not depend on another feature's implementation; a feature may not depend on a data module; a data module may not depend on a feature; a feature api module may depend only on :domain.
  • Baseline of the 5 known feature-to-feature edges. Anything not on that list fails immediately, and the task reports baseline entries that have become stale so they can be deleted as edges are removed.
  • New asl.android.feature convention applied by all seven feature modules.
  • CI runs verifyModuleBoundaries and the convention-plugin tests in the static-analysis job.

Correctness details that matter

Collection is lazy, not eager. A subproject's dependencies { } block has not run while the root project is configuring. Reading the graph during apply would observe an empty list and pass no matter what was declared — a check that always succeeds is worse than no check. The edges are read through a provider that is realised after every project is evaluated.

Only Strings cross into the task. Holding a Project, Configuration or Dependency past configuration time is incompatible with the configuration cache, which this build has enabled. ProjectDependency.path is used because dependencyProject was removed in Gradle 9.

Every production configuration is checked, not just implementation. Otherwise the rule is evadable by declaring the same edge as api, compileOnly, runtimeOnly or a variant-specific configuration. Test and tooling configurations are exempt on purpose, so a feature may still exercise a real implementation from its own tests.

Why the feature convention is small

It applies the Android/Compose defaults and adds the Koin BOM and :domain — things every feature needs by definition. Capabilities stay in each feature's build file.

Eight dependencies currently appear in all seven feature modules, which looks like an argument for centralising more. It is not: :feature:buildrun declares Compose UI, Material and koin-androidx-compose while containing no @Composable at all. That commonality is copy-paste, and moving it into a convention would cement it and quietly grant those dependencies to modules that never wanted them.

Behavior

No behavior change. One new verification task, one convention plugin, and seven build files that declare the same dependencies through a different route.

Scope

Not included: removing :feature:buildrun's unused Compose dependencies, and the data.local / data.buildsystem split packages. Both are separate changes.

The baseline is not a place to add things. It has five entries, each corresponding to a real feature-to-feature edge, and a test asserts that every entry still maps to an actual violation so a stale entry cannot sit there unnoticed.

Tests

  • 11 unit tests for the rule logic, which is deliberately written as pure Kotlin with no Gradle types so it can be tested directly: each rule, the test-configuration exemption, the "cannot evade via another production configuration" case, baseline handling, and a test that the shipped baseline corresponds exactly to the real violations.
  • 718 tests passing overall, 0 failures.

The check was verified to actually fail, not merely to pass. Injecting implementation(projects.feature.git) and implementation(projects.data.local) into :feature:onboarding produced both expected violations with actionable messages; the file was then restored. Without that step, a check that silently observes nothing looks identical to a passing one.

Configuration cache was verified across two consecutive runs — entry stored, then reused.

Verification

From a clean worktree at the branch tip:

  • ./gradlew test detekt verifyModuleBoundaries :app:assembleDebug — BUILD SUCCESSFUL
  • ./gradlew -p build-logic test — BUILD SUCCESSFUL, 11 tests
  • Boundary report: 113 project dependencies checked, 5 baseline entries, 0 violations

@AhmadKharfan
AhmadKharfan merged commit 94d798f into develop Jul 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant