Opening

Modularization is one of those topics where the benefits are loudly celebrated and the costs are quietly endured. You have probably seen talks and blog posts about how splitting your app into modules improves build times, enforces separation of concerns, and enables parallel development. All of that is true. But what you rarely hear about is the other side: the complexity that modularization introduces. API surfaces. Dependency graphs. Build configuration overhead. Navigation nightmares. Today we are going to talk about both sides honestly, so you can make an informed decision about whether and how to modularize your project.

Why People Modularize

Let us start with the genuine benefits, because they are real. The primary driver for modularization is build time. Gradle builds your modules in parallel. If module A and module B do not depend on each other, they can compile simultaneously. When you change a file in module A, only module A and its dependents need to recompile — not the entire project. For large codebases, this can cut build times dramatically. I have seen projects go from five-minute incremental builds to forty-second incremental builds after thoughtful modularization. The secondary benefit is encapsulation. Kotlin's internal visibility modifier actually means something when you have modules. Within a module, internal classes and functions are accessible. Outside, they are invisible. This is the only way in Kotlin to truly hide implementation details from the rest of your codebase.

The Build Time Reality

But let us be precise about build times, because this is where the sales pitch gets fuzzy. Modularization improves build times only under specific conditions. First, your project has to be large enough. If your single-module app builds in thirty seconds, splitting it into fifteen modules will not make it build in two seconds. Gradle itself has overhead — configuration time, dependency resolution, task graph computation. Each module adds to this overhead. For small projects, the overhead can actually make builds slower. Second, your dependency graph matters enormously. If module A depends on module B which depends on module C, they cannot build in parallel — they form a chain. Parallelism comes from breadth, not depth. The ideal module graph is wide and shallow. Deep dependency chains give you the overhead of modules without the parallelism benefit.

Module Boundaries Create API Surfaces

Here is the first cost nobody warns you about. When you extract code into a separate module, you are creating an API surface. Every class, function, and interface that other modules need to access must be public. In a single-module app, you can use internal freely. You can refactor anything without worrying about consumers. Once you have modules, refactoring a public class means checking every module that depends on it. Internal becomes public. Implementation details become API contracts. And API contracts are hard to change. This is especially painful early in a project when your code is still evolving rapidly. You split into modules, define public APIs, and then realize the boundaries were wrong. Now you have to migrate APIs, update consumers across multiple modules, and manage the transition. Modularization works best when your architecture is stable. Doing it too early locks in decisions you have not validated yet.

The Dependency Graph Problem

As your module count grows, managing dependencies becomes a real challenge. Module A needs a class from module B. Module B needs a class from module C. Module C needs a class from module A. Congratulations — you have a circular dependency, and Gradle will not compile your project. Circular dependencies are surprisingly easy to create, especially with feature modules. The user profile feature needs the image loading utility. The image loading utility needs the analytics tracker. The analytics tracker needs the user profile for user ID. Each dependency seems reasonable in isolation, but together they form a cycle. The fix is usually extracting shared code into a common module. But now your common module grows and grows, becoming a dumping ground for anything shared. Eventually your common module is half the codebase, and you have not actually separated anything.

Build Configuration Multiplies

Every module needs its own build.gradle file. Or build.gradle.kts if you use the Kotlin DSL. Each one specifies its plugins, its dependencies, its Android configuration — compileSdk, minSdk, build features. When you have thirty modules, you have thirty build files to maintain. When you upgrade a dependency version, you update it in thirty places — or you centralize it, which adds its own complexity with version catalogs or convention plugins. Convention plugins are the right answer, but they are not simple. You are essentially writing a build system for your build system. You need a buildSrc or an included build that defines shared configuration. This is powerful once set up, but it is real engineering work that you would not need in a single-module project. I have seen teams spend weeks getting their convention plugins right.

Navigation Across Modules

Navigation is perhaps the most painful aspect of multi-module Android projects. In a single-module app, you can navigate by directly referencing the destination Fragment or Composable. Simple. Once you have feature modules that do not depend on each other — which is the whole point — module A cannot reference module B's screens directly. They do not know about each other. The solutions are all compromises. You can use a shared navigation module that defines routes as strings or sealed classes. You can use deep links. You can use a service locator pattern where each module registers its screens. You can use the Navigation component with global actions. All of these work, but all of them replace compile-time safety with runtime indirection. A simple refactoring of a screen name becomes a hunt for string references across modules. Type safety is reduced. The IDE cannot help you as much.

When to Modularize

Given all these costs, when is modularization actually worth it? Here is my honest assessment. Modularize when your clean build time exceeds two to three minutes AND your team has more than five developers. Below those thresholds, the coordination overhead outweighs the build time savings. Modularize when you have clear, stable feature boundaries — a payments feature, a messaging feature, a profile feature — that are genuinely independent and owned by different sub-teams. Modularize when you need to enforce encapsulation with Kotlin's internal modifier because code review alone is not preventing architectural violations. And modularize when you are building a library or SDK that other teams or projects consume — that is actually the strongest case for modularization.

When NOT to Modularize

Do not modularize if you are a solo developer or a team of two or three. The overhead will slow you down more than the build time savings help. Do not modularize if your project is less than six months old and the architecture is still evolving. You will draw the wrong boundaries and spend months fixing them. Do not modularize just because a blog post told you to. "But Google does it" — Google has hundreds of engineers working on single apps. Your situation is different. And do not modularize in one big bang. If you do decide to modularize, do it incrementally. Extract one module at a time, starting with the most independent code — utility libraries, shared models, a single stable feature.

Feature Modules vs. Layer Modules

There are two main strategies for how to split modules, and the choice matters. Layer modules split by architectural layer: an app module, a domain module, a data module, a presentation module. This gives you clean dependency direction but does not help with parallelism — data depends on domain, presentation depends on domain and data. They still form a chain. Feature modules split by user-facing feature: a chat module, a profile module, a checkout module. These can be truly independent, enabling maximum parallel builds. But they cross architectural layers — each feature module has its own presentation, domain, and data code. The best approach in practice is a hybrid. Shared infrastructure in layer modules — networking, database, analytics. Feature-specific code in feature modules. This gives you both the parallelism of feature modules and the shared foundation of layer modules.

The Gradle Configuration Overhead

Let me give you a concrete sense of the Gradle overhead. In a single-module project, you have one build.gradle file. In a thirty-module project, you have thirty build files plus a settings.gradle that lists all modules, plus potentially a buildSrc or convention plugin project. Gradle's configuration phase — where it reads all build files and sets up the task graph — scales with module count. I have measured projects where Gradle configuration alone takes eight to twelve seconds on a thirty-module project. That is twelve seconds before any actual compilation starts. Sync time in Android Studio also scales with module count. Every time you open the project or change a build file, the IDE re-syncs all modules. On large projects, this can take thirty seconds to a minute. These are real costs that compound throughout your day.

Closing

Here is the bottom line on modularization. It is a powerful tool with real benefits — faster parallel builds, true encapsulation, team independence. But it comes with real costs — API surface management, dependency graph complexity, build configuration overhead, navigation challenges. The question is not "should I modularize?" It is "do the benefits exceed the costs for my specific project, team size, and stage?" For most small-to-medium Android projects, a single module or two to three modules is the sweet spot. For large projects with big teams, thoughtful modularization is essential. Start simple, modularize incrementally, and always measure your build times before and after. In our next video, we tackle the most overused tool in a developer's toolkit: abstraction. When not to abstract, and why premature abstraction might be worse than premature optimization.