Design the module dependency graph for a large multi-module app with 20+ feature teams
App, core, and feature layering, an API/impl split that keeps features from coupling to each other, and the CI rule that keeps the graph honest.
LEARN BY DOINGPut your intuition to work3 steps
Design the module dependency graph for a large multi-module app with 20+ feature teams
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Clarify why we're modularizing
Modularization is not free, so I'd start by pinning down which problem it's solving here, because with 20+ teams it's usually more than one: build times (a monolithic module means every commit from every team invalidates the same incremental build cache), merge friction (everyone editing the same handful of files), the need for teams to own a clear boundary of code, and possibly on-demand delivery (shipping a feature as a Dynamic Feature Module so the base install stays small). The answer shapes how strict the boundaries need to be — a codebase optimizing purely for build time can be looser about API surfaces than one that's also trying to let 20 teams ship independently without stepping on each other.
#The layering model
The graph has three layers, and the dependency arrow only ever points one way — down. :app depends on feature modules; feature modules depend on core modules; core modules depend on nothing above them. The rule that actually matters in practice is the one that's easy to violate under deadline pressure: feature modules never depend on each other's implementation. Without that rule enforced somewhere other than a wiki page, "just this once" cross-feature imports accumulate until the module graph is a monolith with extra Gradle files.
#The API/impl split, and navigation without coupling
Each feature is actually two Gradle modules: a small -api module holding interfaces and navigation-destination contracts, and an -impl module with the real implementation. Other features are allowed to depend on :feature-x-api; nothing outside the owning team may depend on :feature-x-impl. This is what makes "feature checkout needs to open a screen owned by feature search" solvable without checkout importing search's internals: checkout depends on :feature-search-api's navigation contract (a typed route key), and a shared :core-navigation registry resolves that key to the actual destination at runtime. It's dependency inversion applied to screen-to-screen navigation, and it's the piece that most designs skip until the first real cross-feature navigation request forces the issue.
#Making the graph enforceable, not just documented
The mechanism that keeps this true over time is a CI check — a Gradle dependency-graph analysis (or a plugin purpose-built for this) that fails the build the moment a module declares a disallowed edge: a cycle, a -impl-to--impl cross-feature dependency, or a leaked concrete type escaping through an -api module's public surface. CODEOWNERS maps each module to the team that owns it, so a pull request touching :feature-checkout-impl automatically routes for review to the checkout team and nobody else needs to approve it. Documentation drifts from reality within a quarter; a red CI check does not.
#What modularization buys, concretely
Gradle's incremental and parallel build model means a change confined to :feature-search-impl only rebuilds and retests that module and its dependents — not all 20 teams' code. Feature modules can also be built and tested against a small sample/demo app instead of the full :app target, so a single team's CI pipeline for their own pull request doesn't need to compile the other 19 teams' features at all. Combined with a remote build cache shared across CI machines and developer laptops, this is usually the single biggest lever for keeping build times sane as team count grows — modularization is as much a build-performance strategy as an architecture one.
#Anti-patterns to name
The most common failure mode is :core becoming a dumping ground — once everything depends on it, a one-line change anywhere inside it forces a rebuild of all 20 teams' features, which is exactly the bottleneck modularization was meant to remove. The fix is splitting core by concern (core-ui, core-network, core-database, core-navigation) rather than one giant core-common, so a change to networking doesn't invalidate a build depending only on the design system. A second failure mode is leaking -impl types through an -api module's public functions — the compiler won't catch this by itself unless the -api module doesn't even have -impl on its dependency classpath, which is exactly how to structure the Gradle build to make the leak impossible rather than merely discouraged. A third, subtler one is over-fragmentation: modules so small that Gradle configuration overhead outweighs any build-time win — group related classes rather than creating a module per class.