Android modularization is one of those topics that sounds like over-engineering right up until your single :app module hits a size where every small change triggers a long rebuild, three developers keep editing the same files, and nobody can explain which screen depends on which repository. I've spent 8+ years building Android apps with Kotlin, first as a Senior Android Engineer at CodeBrew Labs and now in remote senior work and freelance client projects, and splitting apps into modules is one of the few architectural changes I've seen pay off consistently. This post covers how I actually structure multi-module Kotlin apps, the Gradle setup that keeps it sane, and how to migrate an existing monolith without stopping feature work.
Why Android Modularization Matters Once an App Grows
Let me be honest first: if you're building a small app alone, you probably don't need ten modules. Modularization has real costs. More build files, more decisions about where code lives, and a learning curve for anyone joining the project.
But the benefits show up quickly once a codebase grows past a certain point:
- Faster incremental builds. Gradle can skip recompiling modules that didn't change and run independent modules in parallel. In one big module, touching a single file can force a recompile of far more than you'd expect.
- Enforced boundaries. In a monolith, anything can import anything. With modules, if
:feature:bookingsdoesn't declare a dependency on:feature:payments, it simply can't reach into its internals. Architecture stops being a suggestion in a wiki and becomes something the compiler checks. - Parallel team work. Different people can own different feature modules with far fewer merge conflicts.
- Cleaner handoffs. This one matters a lot in freelance work. When I hand a project back to a client's in-house team, a modular codebase is much easier to explain: here's the feature, here's its data layer, here's what it's allowed to touch.
The internal visibility modifier in Kotlin also becomes genuinely useful in a modular project, because it limits visibility to the module. That alone eliminates a whole category of accidental coupling.
How I Structure Modules: app, feature, core
I've tried layer-based splits (one module for all data, one for all domain, one for all UI) and feature-based splits. Pure layer-based modules tend to become giant buckets again. What works best for me is a hybrid: feature modules for user-facing functionality, and core modules for shared building blocks.
:app // Application class, NavHost, DI graph root
:feature:bookings // Screens, ViewModels for bookings
:feature:profile:api // Public contract (routes, interfaces)
:feature:profile:impl // Actual profile UI + logic
:core:designsystem // Theme, typography, shared composables
:core:domain // Use cases, domain models
:core:data // Repositories, Room, Retrofit
:core:network // HTTP client, interceptors, auth
:core:testing // Fakes, test rules, fixtures
:build-logic // Gradle convention pluginsThe rules I follow
- Features never depend on other feature implementations. If bookings needs to open the profile screen, it depends on
:feature:profile:api, never on:impl. - Core modules never depend on features. Dependencies flow inward, toward core.
:appis thin. It wires everything together. If business logic shows up in:app, it's in the wrong place.- Split api/impl only when needed. Not every feature needs two modules. I only split when another feature needs to navigate to it or call into it.
Tip
Start with fewer, slightly bigger modules. It's easy to split a module later. Merging modules back together after people have built around the boundaries is much more painful.
Gradle Convention Plugins to Kill Build Script Duplication
The first thing that goes wrong in multi-module projects is copy-pasted build.gradle.kts files. Fifteen modules, fifteen slightly different compileSdk values, and someone forgot to enable Compose in one of them. Convention plugins fix this by putting shared configuration in one place.
I keep them in an included build called build-logic. Here's a trimmed version of the feature plugin I use:
// build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlin.plugin.compose")
apply("com.google.devtools.ksp")
apply("com.google.dagger.hilt.android")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
extensions.configure<LibraryExtension> {
compileSdk = 35
defaultConfig.minSdk = 24
buildFeatures.compose = true
}
dependencies {
add("implementation", project(":core:designsystem"))
add("implementation", project(":core:domain"))
add("implementation", libs.findLibrary("hilt-android").get())
add("implementation", libs.findLibrary("androidx-lifecycle-viewmodel-compose").get())
add("ksp", libs.findLibrary("hilt-compiler").get())
add("testImplementation", project(":core:testing"))
}
}
}Register it in the build-logic build file:
// build-logic/convention/build.gradle.kts
gradlePlugin {
plugins {
register("androidFeature") {
id = "myapp.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
}
}And now a feature module's build file is almost nothing:
// feature/bookings/build.gradle.kts
plugins {
id("myapp.android.feature")
}
android {
namespace = "com.myapp.feature.bookings"
}
dependencies {
implementation(project(":feature:profile:api"))
}When you bump the SDK or add a lint rule, you change one file. That's the whole point.
api vs implementation matters more than people think
Use implementation by default. Only use api when a module's public signatures expose types from its dependency. Overusing api leaks dependencies transitively, which means a change deep in core can trigger recompiles across the entire graph and quietly undoes the build speed benefits you modularized for.
Dependency Rules and Navigation Between Feature Modules
The question I get most from developers trying this for the first time: "If features can't depend on each other, how does one screen open another?"
The answer is the api/impl split plus dependency injection. The api module holds a small contract. The impl module provides it. The :app module knows about every impl and assembles the navigation graph.
// :feature:profile:api
interface ProfileEntry {
val route: String
fun routeFor(userId: String): String
}
// :feature:profile:impl
internal class ProfileEntryImpl @Inject constructor() : ProfileEntry {
override val route = "profile/{userId}"
override fun routeFor(userId: String) = "profile/$userId"
}
@Module
@InstallIn(SingletonComponent::class)
internal abstract class ProfileEntryModule {
@Binds
abstract fun bindProfileEntry(impl: ProfileEntryImpl): ProfileEntry
}
fun NavGraphBuilder.profileGraph(entry: ProfileEntry) {
composable(entry.route) { backStackEntry ->
ProfileScreen(userId = backStackEntry.arguments?.getString("userId").orEmpty())
}
}The bookings feature only sees the interface. It gets a navigation callback from the app layer, so its composables stay free of any knowledge about the profile implementation:
// :feature:bookings
@Composable
fun BookingDetailScreen(
booking: BookingUi,
onOpenHost: (hostId: String) -> Unit
) {
// ...
TextButton(onClick = { onOpenHost(booking.hostId) }) {
Text("View host")
}
}
// :app
NavHost(navController, startDestination = "bookings") {
bookingsGraph(
id -> navController.navigate(profileEntry.routeFor(id)) }
)
profileGraph(profileEntry)
}The same pattern works whether you use string routes or the newer type-safe navigation with serializable route classes. In the type-safe version, the route class itself lives in the api module.
Watch out
Rules you don't enforce will be broken under deadline pressure. Add a simple Gradle check or a dependency analysis plugin in CI that fails the build if a :feature module depends on another feature's :impl. It takes an afternoon to set up and saves months of slow erosion.
Migrating to Android Modularization Without Freezing Delivery
Most real projects aren't greenfield. In client work I usually inherit a single-module app that's been growing for years, and the client (reasonably) doesn't want to pause features for a big rewrite. So migration has to happen alongside normal work.
Here's the order I follow:
- Set up build-logic first. Convention plugins and a version catalog before any code moves. Every new module after that is cheap to create.
- Extract the leaves. Start with code that has no dependencies on the rest of the app: design system components, theme, utility extensions, network client. These are low risk and immediately reusable.
- Extract the data layer next. Repositories and their models move into
:core:data. This is usually where hidden coupling shows up, like a repository that secretly reads from aContext-based singleton in:app. - Pull out one feature at a time. Pick a feature that's actively being worked on, so the extraction rides along with real tickets instead of being a separate project the client has to approve.
- Leave the rest in
:appuntil there's a reason. Legacy screens nobody touches can stay where they are. Modularization is a tool, not a purity test.
Problems I hit regularly
- Resource conflicts. Two modules defining
ic_closeortitle_text. Prefix resources per module, or turn on non-transitive R classes so each module references its own resources explicitly. - Circular dependencies. Usually a sign that a shared model belongs in core. Move the model down, don't add more modules up.
- Hilt setup surprises. Every module with injected classes needs the Hilt plugin and KSP configured. This is exactly why that config belongs in the convention plugin.
- Too much granularity too soon. If you need to open six modules to add one field, you've split too finely.
The goal isn't the most modules. It's the fewest modules that give you clear boundaries and fast builds.
One more practical note: measure before and after. Gradle build scans or the Build Analyzer in Android Studio will tell you whether your changes actually improved build times. I've seen modularization efforts make builds slower because of heavy api usage and a bloated core module that everything depended on. Data beats assumptions.
If you're freelancing, this is also a skill worth putting on your profile in concrete terms. Clients with growing apps often feel the pain of slow builds and tangled code but don't know the word for the fix. Being able to explain a phased modularization plan in a proposal, one that doesn't stop their roadmap, is a strong signal of seniority.
Key Takeaways
- Android modularization pays off once an app and team grow, giving faster incremental builds, compiler-enforced boundaries and easier handoffs, but it's overkill for tiny solo apps.
- Use a hybrid structure: thin
:app, feature modules for user-facing work, core modules for shared code, and api/impl splits only where features need to talk. - Put shared Gradle config in convention plugins and default to
implementationoverapito protect build speed. - Navigate between features through interfaces in api modules and callbacks wired in
:app, and enforce dependency rules in CI. - Migrate incrementally: build-logic first, then leaf modules, data layer, and one active feature at a time, measuring build times as you go.