Lesson 44 of 83 intermediate

Modularization, Feature Modules & Build Tradeoffs

Architect large Android apps with clean module boundaries — the skill that separates seniors from juniors

Open interactive version (quiz + challenge)

Real-world analogy

Modularizing an app is like organizing a large hospital into specialized departments — Cardiology, Radiology, Pharmacy — each with its own staff, equipment, and entrance. A cardiologist does not need access to the pharmacy's prescription database. Clear boundaries mean each department can work independently, scale separately, and be rebuilt without disrupting the whole hospital.

What is it?

Android modularization is the practice of splitting a single large Gradle module into multiple smaller, independently compilable modules with clear dependency rules. Each module has a defined responsibility, reducing coupling, enabling parallel builds, and allowing teams to work independently without merge conflicts.

Real-world relevance

A large enterprise school management platform serving 500 schools grew from 1 module to 28 modules over two years. The :feature:attendance, :feature:grades, and :feature:timetable modules are owned by separate squads. Before modularization, a CSS change in the design system caused a full 11-minute recompile. After, only :core:design-system and the two feature modules using the changed component rebuild — 90 seconds total.

Key points

Code example

// settings.gradle.kts — declare all modules
include(":app")
include(":core:network")
include(":core:design-system")
include(":core:common")
include(":domain:user")
include(":data:user")
include(":feature:login")
include(":feature:dashboard")
include(":feature:grades")

// :core:network/build.gradle.kts
plugins { id("com.android.library") }

dependencies {
    // 'api' — OkHttp type leaks into callers' public APIs
    api("com.squareup.okhttp3:okhttp:4.12.0")
    // 'implementation' — Gson is internal detail, hidden from callers
    implementation("com.google.code.gson:gson:2.10.1")
}

// :feature:grades/build.gradle.kts
plugins { id("com.android.library") }

dependencies {
    implementation(project(":core:network"))
    implementation(project(":core:design-system"))
    implementation(project(":domain:user"))   // interface only
    // WRONG: implementation(project(":feature:login")) — never allowed!
}

// :app/build.gradle.kts — assembles everything
plugins { id("com.android.application") }

dependencies {
    implementation(project(":feature:login"))
    implementation(project(":feature:dashboard"))
    implementation(project(":feature:grades"))
    // :app knows all features; features do NOT know each other
}

// Navigation contract in :core:navigation
object Destinations {
    const val GRADES = "feature/grades"
    const val DASHBOARD = "feature/dashboard"
}

// Each feature registers its NavGraph via a NavigationProvider interface
// resolved by dependency injection — features never import each other

Line-by-line walkthrough

  1. 1. settings.gradle.kts include() calls — each string maps to a directory containing its own build.gradle.kts; Gradle discovers and wires them as project dependencies.
  2. 2. :core:network uses 'api(okhttp)' — because network module callers (like :data:user) likely expose OkHttp types (e.g., Response) in their own APIs; using 'api' propagates OkHttp to those callers automatically.
  3. 3. :core:network uses 'implementation(gson)' — Gson is only used internally for parsing; callers never see Gson types, so 'implementation' hides it and prevents accidental coupling.
  4. 4. :feature:grades depends on project(':core:network') and project(':domain:user') via 'implementation' — it uses these but does NOT expose their types in its own public surface.
  5. 5. The comment '// WRONG: implementation(project(':feature:login'))' — illustrates the cardinal rule: feature modules cannot reference sibling features. This would create a cycle if :feature:login ever needed to show a grade.
  6. 6. :app depends on all feature modules — it is the integration point; it adds each feature to the nav graph and provides the application-scoped DI container that wires everything together.
  7. 7. Destinations object in :core:navigation — defines route strings as constants in a module that both :app (for the nav graph) and individual features (for navigate() calls) can depend on without cross-feature coupling.
  8. 8. NavigationProvider interface — each feature module implements this interface and registers its NavGraph fragment; :app collects all implementations via DI (e.g., Hilt multibindings) at startup.

Spot the bug

// :feature:grades/build.gradle.kts
dependencies {
    api(project(":core:network"))          // Bug 1
    api(project(":core:design-system"))    // Bug 2
    implementation(project(":feature:login"))  // Bug 3
    implementation(project(":data:grades"))
}

// GradeRepository.kt in :feature:grades
class GradeRepository(
    private val api: GradeApi,
    private val db: AppDatabase  // Bug 4
) {
    suspend fun getGrades(): List<Grade> = api.fetchGrades()
}

// :app/build.gradle.kts
dependencies {
    implementation(project(":feature:grades"))
    // :app now can use OkHttp directly without declaring it — Bug 5
}
Need a hint?
Check which dependencies should be 'api' vs 'implementation', which module should own the database, and the cross-feature dependency.
Show answer
Bug 1: 'api(project(:core:network))' — :feature:grades should NOT re-export :core:network to its consumers. The network client is an internal implementation detail of the grades feature. Fix: change to 'implementation(project(:core:network))'. Using 'api' here means :app accidentally gains access to OkHttp types via :feature:grades, which is the transitive leakage shown in Bug 5. Bug 2: 'api(project(:core:design-system))' — design system composables and themes are implementation details of the UI in :feature:grades. Callers of :feature:grades (i.e., :app) do not need design system types. Fix: change to 'implementation(project(:core:design-system))'. Bug 3: 'implementation(project(:feature:login))' — a feature module depending on another feature module is architecturally forbidden. It creates hidden coupling: if :feature:login now needs to link back to grades, you get a circular dependency. Fix: any shared types (e.g., a LoggedInUser) must live in :domain or :core:common; navigation to login from grades goes through a navigation contract in :core:navigation. Bug 4: AppDatabase in :feature:grades — the Room database should live in a :core:database or :data:grades module, not in a feature module. Placing it here means other data modules cannot share the database, and the feature module takes on infrastructure responsibility it should not own. Fix: move AppDatabase to :core:database and inject it via DI. Bug 5: :app can use OkHttp without declaring it — this is the consequence of Bug 1. When :feature:grades uses 'api' for :core:network, OkHttp leaks transitively into :app's compile classpath. This is an invisible dependency: if :feature:grades later changes to 'implementation', :app breaks at compile time even though :app never intentionally depended on OkHttp.

Explain like I'm 5

Imagine your school has one giant room for everyone — students, teachers, the library, the canteen, and the office. When you repaint a wall, EVERYONE must leave. Modularization gives each group their own room. Now when you repaint the canteen, only canteen staff need to leave. Building your Android app this way means changing the login screen does not force you to recompile the timetable screen.

Fun fact

The Google Play Store Android app itself is modularized into 200+ Gradle modules. Their engineers reported that modularization reduced p50 build times by 65% and allowed 500+ engineers to work simultaneously without constant merge conflicts.

Hands-on challenge

Design the module graph for a school management Android app with these features: student login/profile, grade viewing, timetable, attendance marking (teacher only), and an admin dashboard. Identify at least 3 :core modules and 5 :feature modules, define which depend on which, and explain where you would place the Room database and Retrofit client. Justify whether any module should use 'api()' vs 'implementation()' dependencies.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery