---
title: "The Modularization Trap: When Clean Architecture Kills Startup Velocity"
published: true
description: "A practical guide to right-sizing your Gradle module graph — with build-time benchmarks, splitting heuristics, and the patterns that actually ship."
tags: kotlin, android, architecture, performance
canonical_url: https://blog.mvp-factory.com/the-modularization-trap-when-clean-architecture-kills-startup-velocity
---
## What You Will Learn
By the end of this walkthrough, you will know exactly when splitting Gradle modules helps and when it silently kills your team's shipping cadence. I will show you real build-time benchmarks across three configurations, give you a decision framework based on team size, and demonstrate the module structure I reach for on every production Android and KMP project.
## Prerequisites
- A working Android or KMP project with Gradle 8.x
- Familiarity with `build.gradle.kts` and multi-module basics
- Access to `./gradlew --profile` (you already have this)
## Step 1: Measure Before You Split
Before touching your module graph, run this on your actual project:
bash
./gradlew assembleDebug --profile
Open the generated HTML report in `build/reports/profile/`. Look at two numbers: **total build time** and **configuration phase time**. If your incremental build is under 6 seconds, modularization will not save you. It will cost you. Write those numbers down — they are your baseline.
## Step 2: Understand the Real Cost
I benchmarked a production KMP project (85K lines of Kotlin, REST + Room + Compose) across three setups on an M2 Pro MacBook and GitHub Actions CI.
| Metric | Monolith (1) | Balanced (5) | Over-modularized (30) |
|---|---|---|---|
| Clean build (local) | 42s | 38s | 67s |
| Clean build (CI) | 2m 18s | 2m 05s | 3m 52s |
| Incremental (1 file) | 8s | 4s | 6s |
| Configuration phase | 0.9s | 2.1s | 11.4s |
| Cross-module refactor | N/A | ~15 min | ~90 min |
The docs do not mention this, but that 11.4-second configuration phase hits on *every single build* before Kotlin even compiles. On CI, this compounds into nearly double the clean build time.
## Step 3: Pick the Right Module Count
Here is the heuristic I use in every project:
| Team Size | Modules | 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 |
## Step 4: Structure Feature-Vertical, Not Layer-Horizontal
Let me show you a pattern I use in every project. Stop splitting by `:data`, `:domain`, `:presentation`. That creates coupling that scales with feature count. Split by feature instead:
kotlin
// DON'T: Layer-based modules — every feature touches all three
// :data → :domain → :presentation
// 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)
Each feature module owns its data, domain, and UI layers internally. Teams ship independently. No cross-module refactors for a single feature change.
## Step 5: Apply the Rule of Two
Only create a new module when **at least two** of these are true:
1. A distinct team owns that code and ships on a different cadence
2. Incremental builds exceed 10 seconds in that area
3. The code is genuinely reused across multiple applications — not "might be reused someday"
If you are splitting because "it feels cleaner," stop. Here is the minimal setup to get this working: run `--profile`, check the numbers, and only split when the data demands it.
## Gotchas
**Configuration cache is not a silver bullet.** Gradle's configuration cache (stable since 8.1) can drop that 11.4s config phase to ~1.2s on cache hit. But cache invalidation fires on any `build.gradle.kts` change. In a 30-module project, someone edits a build file almost daily. The win rarely survives a real sprint.
**Circular dependency whack-a-mole.** At 30 modules, teams burn 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 are creating `:common:shared-models` — a junk-drawer module that defeats the purpose.
**Onboarding scales linearly with module count.** Budget roughly 0.5 extra days per 10 modules for a mid-level engineer to become productive. In a monolith, tracing a feature means reading one build file. In 30 modules, it means navigating a dependency graph that looks like a conspiracy board.
**API surface explosion.** Every module boundary forces `internal` vs `public` decisions. Teams over-expose APIs to avoid recompilation cascades, ending up with 40+ public classes per "encapsulated" module.
## Conclusion
Good architecture is not about how many boxes appear in your dependency diagram. It is about knowing which boundaries actually reduce complexity versus which ones just redistribute it. Measure your builds, split by team ownership, apply the Rule of Two, and structure features vertically. The best module is the one you did not create.
Top comments (0)