The Modularization Trap: When Clean Architecture Becomes Your Startup's Bottleneck
TL;DR
More modules do not equal better architecture. Teams under 10 engineers consistently lose 15-30% of their development velocity to over-modularized codebases. I’ve watched it happen repeatedly on production Android and KMP systems. The sweet spot depends on team size, feature velocity, and deployment topology — not on how many conference talks you watched on Clean Architecture. I’ll walk you through the data, the patterns, and the heuristics I use to right-size a module graph.
The allure of splitting everything
Paul Graham wrote about “good taste” as the ability to distinguish between elegant simplicity and unnecessary complexity. That distinction is exactly what most Android teams fail to make when they reach for modularization.
The pitch is seductive: separation of concerns, faster incremental builds, enforced dependency boundaries, reusable feature modules. Every architecture talk at Droidcon reinforces this. So teams go from a monolith to 30+ Gradle modules, and then something breaks. Not the code. The team’s ability to ship.
What most teams get wrong: they optimize for theoretical purity instead of actual throughput.

The build-time reality
I benchmarked a real-world KMP project (85K lines of Kotlin, REST + Room + Compose) across three configurations on an M2 Pro MacBook and a CI runner (GitHub Actions, 4-core).
| Metric | Monolith (1 module) | Balanced (5 modules) | Over-modularized (30 modules) |
|---|---|---|---|
| Clean build (local) | 42s | 38s | 67s |
| Clean build (CI) | 2m 18s | 2m 05s | 3m 52s |
| Incremental build (1 file change) | 8s | 4s | 6s |
| Configuration phase | 0.9s | 2.1s | 11.4s |
settings.gradle.kts complexity | 3 lines | 12 lines | 94 lines |
| Avg. cross-module refactor time | N/A | ~15 min | ~90 min |
The 5-module configuration wins on incremental builds while keeping configuration overhead manageable. The 30-module setup pays a brutal tax on every single build: 11.4 seconds just in Gradle’s configuration phase before a single line of Kotlin compiles. On CI, that overhead compounds into nearly double the clean build time.
The hidden costs nobody benchmarks
Build time is the visible cost. The invisible ones hurt more.
Circular dependency whack-a-mole
At 30 modules, teams spend 3-5 hours per sprint resolving dependency cycles. You extract :feature:profile, but it needs a type from :feature:settings, which depends on :core:navigation, which references :feature:profile. Now you’re creating :common:shared-models, a junk-drawer module that defeats the entire purpose.
Onboarding friction
New engineers on a monolith can trace a feature end-to-end by reading one build.gradle.kts. In a 30-module project, understanding “where does the login flow live?” requires navigating a dependency graph that looks like a conspiracy theory board. I’ve seen onboarding time scale roughly linearly with module count: about 0.5 extra days per 10 modules for a mid-level engineer to become productive.
API surface explosion
Every module boundary forces you to decide: internal or public? Teams over-expose APIs to avoid recompilation cascades, and suddenly your “encapsulated” modules have 40+ public classes each. You traded one big implicit dependency graph for one big explicit dependency graph with more boilerplate.
Right-sizing heuristics
This is the framework I use:
| Team Size | Recommended Modules | Split Strategy |
|---|---|---|
| 1-3 engineers | 1-3 | Monolith + shared KMP module if multiplatform |
| 4-8 engineers | 4-8 | Split by deployment boundary (app, shared, platform) |
| 9-20 engineers | 8-15 | Split by team ownership, not by layer |
| 20+ engineers | 15+ | Feature modules aligned to squad boundaries |
The one rule
Split when you have a team boundary, not when you have a layer boundary. The :data, :domain, :presentation trinity from Clean Architecture textbooks creates coupling across every feature. Feature-vertical modules (:feature:checkout containing its own data, domain, and UI layers) let teams ship independently.
// DON'T: Layer-based modules create cross-cutting dependencies
// :data → :domain → :presentation (every feature touches all three)
// DO: Feature-vertical modules with a thin shared core
// :app
// :core (DI, networking, design system)
// :feature:auth (data + domain + ui for auth)
// :feature:checkout (data + domain + ui for checkout)
When to actually split
Split a module when at least two of these are true:
- A distinct team owns that code and ships it on a different cadence
- Build times exceed 10 seconds for incremental changes in that area
- The code is genuinely reused across multiple applications (not “might be reused someday”)
If you’re splitting because “it feels cleaner,” stop. Taste without data is just aesthetics.
The Gradle configuration cache caveat
Gradle’s configuration cache (stable since 8.1) changes the math slightly. With it enabled, the 30-module configuration phase drops from 11.4s to ~1.2s on cache hit. But cache invalidation happens on any build.gradle.kts change, and in a 30-module project, someone is editing a build file almost daily. The theoretical improvement rarely survives contact with a real sprint.
What to actually do
Run --profile on your actual build before you split anything. If incremental builds are under 6 seconds, modularization won’t save you. It will cost you. Measure configuration phase time separately; that’s the silent killer.
Split by team, not by layer. Feature-vertical modules aligned to team ownership reduce coordination overhead. Layer-based splits (:data, :domain, :presentation) create coupling that scales with feature count, not team count.
And apply the Rule of Two. Don’t create a module until at least two of the three criteria (team boundary, build-time pain, genuine reuse) are met. Premature modularization is premature abstraction wearing a build system disguise.
Good architecture taste, as Graham would put it, isn’t about how many boxes are in your dependency diagram. It’s about knowing which boundaries actually reduce complexity versus which ones just redistribute it. The best module is the one you didn’t create.