Wassim Beltaief

Splitting the app into modules

Tuesday, September 20, 2022

The app has one module. Everything in it. 300 files, 80.000 lines of Kotlin. Clean build takes 8 minutes. Any change rebuilds a large portion. The team is growing and multiple people editing the same module causes constant conflicts.

We decide to modularize.

Why modularize

Two main reasons in our case: build speed and team ownership.

Gradle only recompiles what changed. In a single module, touching one file can trigger recompilation of the entire module. With feature modules, changing the Search module only rebuilds Search. Other modules use a cached output.

Also, modules have clear boundaries. The Profile team owns the :feature:profile module. They can change internals freely without affecting other teams.

The module structure

We land on this shape:

:app                    (thin shell, wires everything)
:core:ui                (shared UI components)
:core:network           (API client, models)
:core:data              (repositories)
:feature:home
:feature:search
:feature:profile
:feature:settings

Core modules have no dependencies on feature modules. Feature modules depend on core modules but not on each other.

The API/implementation pattern

Modules should hide their internals. If :feature:search exposes all its classes, other modules will start depending on them and you lose the boundary.

We use an API module for each feature:

:feature:search:api    (public interface, navigation contract)
:feature:search:impl   (actual implementation)
// :feature:search:api
interface SearchNavigator {
    fun openSearch(query: String? = null)
}

Other modules depend on :feature:search:api. Only :app depends on :feature:search:impl and wires the implementation.

This pattern feels heavy at first. But it prevents the accidental dependencies that make modularization useless.

Navigation between modules

This is the hardest part.

Before modularization, navigating from Home to Search is one line with a nav action. After modularization, Home and Search are in different modules. Home cannot import anything from Search.

Solution: each feature module defines its destination in its API module. The :app module registers all destinations in the nav graph:

// in :app
NavHost(navController, startDestination = "home") {
    homeGraph(navController)  // provided by :feature:home:impl
    searchGraph(navController) // provided by :feature:search:impl
}

Each feature contributes its own navigation graph. Home triggers navigation by route string, not by importing Search directly.

What bites us

Circular dependencies. Module A depends on module B which depends on module A. Gradle refuses to build. Fixing this requires extracting shared types to a :core module. Happens more than expected.

Build configuration duplication. Every module needs build.gradle with the Android plugin, Kotlin, Hilt setup. 15 modules means 15 build files that are mostly identical. We eventually write a convention plugin to share this.

Hilt. Multi-module Hilt setup requires @InstallIn components in the right modules and makes sure the Hilt processor runs correctly in each module. There are a few frustrating hours with missing bindings that only fail at runtime.

Result

After three months the main module builds in 90 seconds instead of 4 minutes when you only change a feature module. Full clean build is still 3 minutes but that is rare.

The team boundaries are real and respected. The refactor is worth it but plan for it to take longer than you think.