MVP Factory
The Modularization Trap: When Clean Architecture Becomes Your Startup's Bottleneck
ai startup development

The Modularization Trap: When Clean Architecture Becomes Your Startup's Bottleneck

KW
Krystian Wiewiór · · 5 min read

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 Modularization Trap: When Clean Architecture Becomes Your Startup's Bottleneck

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).

MetricMonolith (1 module)Balanced (5 modules)Over-modularized (30 modules)
Clean build (local)42s38s67s
Clean build (CI)2m 18s2m 05s3m 52s
Incremental build (1 file change)8s4s6s
Configuration phase0.9s2.1s11.4s
settings.gradle.kts complexity3 lines12 lines94 lines
Avg. cross-module refactor timeN/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 SizeRecommended ModulesSplit Strategy
1-3 engineers1-3Monolith + shared KMP module if multiplatform
4-8 engineers4-8Split by deployment boundary (app, shared, platform)
9-20 engineers8-15Split by team ownership, not by layer
20+ engineers15+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:

  1. A distinct team owns that code and ships it on a different cadence
  2. Build times exceed 10 seconds for incremental changes in that area
  3. 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.


Share: Twitter LinkedIn