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: the Application class, the main Activity, 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

──depends on──▶

:feature-trails
:feature-auth

Screens & ViewModels

──depends on──▶

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

Note: Every module layout and build script shown here is correct, real Gradle and Kotlin as it would appear in an actual multi-module Android Studio project — but this website's own project doesn't run a live Android Studio build, so nothing here executes on the page itself.

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.

Up next: Gradle Kotlin DSL looks more closely at the build scripts that hold a project like this together, module or no module.