Modularization
Clean Architecture split TrailLog into layers within a single Gradle module. Modularization takes the same instinct for separation one step further and splits the codebase across separate Gradle modules entirely, each compiled somewhat independently. It solves a problem Clean Architecture on its own doesn't: as a single-module app grows large, every single change — even a one-line fix deep inside a feature nobody's touched in months — forces Gradle to recompile and re-test the entire app, because everything lives in one compilation unit. This lesson covers why that gets slow, and how splitting TrailLog into real modules fixes it.
The Problem: One Giant Module Means One Giant Rebuild
Picture TrailLog as it might look after a year of active development: trail list and detail screens,
a login and account flow, a Room database layer, a networking layer for syncing to the cloud, plus
everything from earlier lessons — Firebase, WorkManager, CameraX. If every class lives in one
module named :app, Gradle has no way to know that a change to, say, a login screen's
button color couldn't possibly have affected the Room database code. It doesn't track dependencies
at that granularity within a single module — it just recompiles the whole thing, then re-runs every
test that module contains, every time, for every change. On a small app that's instant. On a large
one, with a real team pushing changes all day, it adds up to minutes lost hundreds of times a day.
The Fix: Split TrailLog Into Separate Gradle Modules
Modularizing means giving each meaningful piece of TrailLog its own Gradle module — its own
directory, its own build.gradle.kts, and its own compilation boundary. A reasonable
split for TrailLog looks like this:
:app— the application module itself: theApplicationclass, the mainActivity, Hilt's application-level setup, and almost nothing else.:feature-trails— every screen and ViewModel for browsing, viewing, and editing trails.:feature-auth— the login and account screens.:core-database— the Room database, DAOs, and entities (see Room Database).:core-network— the Retrofit service and DTOs (see Retrofit + Coroutines).
Each module gets its own minimal build.gradle.kts, declaring only what it actually
needs. :feature-trails's might look like this:
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
}
android {
namespace = "com.traillog.feature.trails"
compileSdk = 34
}
dependencies {
implementation(project(":core-database"))
implementation(project(":core-network"))
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
}
Notice the module is a library module (com.android.library), not an
application module — only :app produces an installable APK. Every feature and core
module compiles down to an Android library that :app ultimately depends on and bundles
together.
The Dependency Rule: Features Depend on Core, Never the Reverse
:app
Wires everything together
:feature-trails
:feature-auth
Screens & ViewModels
:core-database
:core-network
Room, Retrofit
The rule that keeps a multi-module codebase from turning into a tangle is simple to state and easy
to enforce: feature modules may depend on core modules, but a core module must never depend
on a feature module. :core-database knows nothing about
:feature-trails and never imports anything from it — it just exposes a
TrailDao that any feature module is free to consume. If :core-database
ever needed to import something from :feature-trails, that would be a sign the
boundary was drawn in the wrong place. This mirrors exactly the one-way dependency rule from Clean
Architecture, just enforced at the Gradle module level instead of the package level — Gradle itself
will refuse to compile a genuine circular dependency between modules, which turns a design guideline
into something the build actually enforces.
Why This Actually Makes Builds Faster
This is the practical payoff, and it's worth being precise about why it works: Gradle tracks
dependencies between modules and only recompiles a module — and the modules that depend on it — when
something inside it actually changed. Fix a bug purely inside :feature-auth, and Gradle
can reuse its already-compiled, unchanged output for :feature-trails,
:core-database, and :core-network entirely, recompiling only
:feature-auth and re-linking :app. On a large codebase with many
independent modules, that turns a full-project rebuild into a rebuild of one small corner of it —
often the difference between a multi-minute build and one that finishes in seconds.
Dynamic Feature Modules: On-Demand Delivery
A related but distinct idea is the Dynamic Feature Module — a module that Play
Store can deliver to a user's device after the initial install, on demand, rather than
bundling it into the base install every user downloads. If TrailLog later added a heavyweight,
rarely-used feature — say, an offline map-tile downloader used by only a fraction of hikers — that
feature could ship as a dynamic feature module using the com.android.dynamic-feature
Gradle plugin, keeping the base app's download size smaller for the vast majority of users who never
need it. This is a genuinely separate tool from the plain library modules covered above, aimed
specifically at reducing install size rather than build speed.
Being Honest About When This Is Worth It
Modularizing a small app is close to pure overhead: more build.gradle.kts files to
maintain, more indirection between "I want to change this button" and the file that defines it, and
build-time savings that only show up once a project is genuinely large. Like Clean Architecture in
the previous lesson, this is a scaling technique that earns its cost once a codebase — and
critically, the team working on it — has grown enough that build times and merge conflicts
in one shared module are actually causing pain day to day. TrailLog as built across this track has
stayed a single module throughout, exactly because that was the right fit for one running example;
reaching for full modularization on day one of a new solo project is usually solving a problem that
doesn't exist yet.