Skip to content
Muhammed Ali
Kocabey

Modularizing Android Apps: Feature, Core and Data Boundaries

Modularizing an Android app means splitting one monolithic Gradle module into many smaller ones, each with a single responsibility and a strict rule about what it is allowed to depend on. The structure that holds up in practice is a thin :app entry point, one :feature:* module per user-facing area, a set of shared :core:* libraries, and :data modules that own repositories and data sources. Clean Architecture does not fight this layout; it maps directly onto it. Presentation lives in feature modules, the domain contracts live in a pure-Kotlin core module, and the data layer implements those contracts. The whole system works because dependencies are only ever allowed to point in one direction.

I have built and refactored enough multi-module Android projects, including in a production banking app, to be convinced that the module graph is not a diagram you draw once. It is a constraint you enforce on every pull request. This post is about the boundaries that matter, which way the arrows should point, and the mistakes that quietly turn a modular project back into a monolith with extra steps.

Key takeaways

  • Four module roles: :app is the entry point, :feature:* are vertical slices of the product, :core:* are horizontal shared libraries, and :data modules encapsulate repositories and data sources.
  • One-directional dependencies: app depends on features; features depend on core and data; nothing depends back up the chain. A feature module must never depend on another feature module.
  • Clean Architecture maps onto Gradle modules: the domain layer is a pure-Kotlin module with no Android types, and the data layer depends on it, not the other way around.
  • Convention plugins keep build logic DRY without the build-cache penalty of buildSrc.
  • The three failure modes are a god :core module, cyclic dependencies, and leaking data-layer models into the UI.

What modularization actually buys you

There are two honest reasons to modularize, and neither of them is "it looks professional."

The first is build time. Gradle only recompiles modules whose inputs changed, and it can build independent modules in parallel. In a single-module app, a one-line change in a settings screen recompiles the world. When that screen is its own :feature:settings module, Gradle recompiles that module and recompiles :app against the changed module — and an ABI-compatible change may not force dependents to recompile at all — leaving every other feature's compiled output untouched in the build cache. On a large codebase this is the difference between a ten-second and a two-minute incremental build.

The second is team scaling. Clear boundaries mean two engineers can work on :feature:checkout and :feature:search without colliding, and a reviewer can reason about a change by looking only at the module it lives in. A module's public surface becomes a contract. This is the same instinct I wrote about when I split a production eID SDK into reading, verification and integration layers: the point of a boundary is that you can change what is behind it without the callers noticing.

What modularization does not buy you for free is good architecture. You can draw perfect module lines and still write tangled code inside them. Modules enforce the boundaries; they do not design them for you.

The module map: :app, :feature, :core, :data

Google's official guide to Android app modularization describes four module types, and I have found the same four roles emerge on every real project.

:app is just the entry point

The app module is the smallest interesting module in the project. It wires everything together: it depends on the feature modules, hosts the root navigation graph, and sets up the dependency-injection graph. It should contain almost no business logic. If you target several form factors, each gets its own app module (:app, :app:wear, :app:tv) so platform-specific dependencies stay isolated. A settings.gradle.kts for a modest project looks like this:

// settings.gradle.kts
includeBuild("build-logic")

include(":app")
include(":core:designsystem")
include(":core:domain")
include(":core:data")
include(":core:model")
include(":feature:articles")
include(":feature:bookmarks")

:feature modules are vertical slices

A feature module is a self-contained slice of the product: its screens, its ViewModels, its UI state, and the navigation into and out of it. A feature is not necessarily a single screen. A "checkout" feature can span a cart, an address step and a confirmation screen; they belong together because they change together.

The hard rule, straight from the official patterns guide, is that a feature module must never depend on another feature module. The moment :feature:checkout imports a class from :feature:search, you have coupled two independently shipping parts of the app, and the dependency graph starts to grow cycles. If two features genuinely need to share something, that something is not a feature; it belongs in a core module.

:core modules are horizontal, shared, and layer-agnostic

Core modules hold code that many features reuse: the design system, networking primitives, common Kotlin utilities, analytics. They are horizontal rather than vertical, and they deliberately do not represent one architectural layer. A :core:designsystem module owns your theme, typography and reusable composables so that every feature looks like the same app. This is also where cross-cutting UI behaviour belongs, the kind of shared extension functions I described in moving back handling to a single reusable helper.

Core modules may depend on other core modules, but they must never depend on a feature or app module. Dependencies flow down into core, never back out.

:data modules own the repositories

A data module encapsulates everything about a domain of data: the repository, the remote and local data sources, and the model classes that cross the network and database. The rest of the app asks the repository for data and never learns whether it came from Retrofit, Room, or an in-memory cache. That encapsulation is exactly what lets you swap a data source, add a cache, or change a serialization format without touching a single feature.

Dependency direction is the whole game

Everything above collapses into one invariant:

:app  →  :feature:*  →  :core:* , :data:*
         (never →  :feature:*)

Arrows point down and to the right, never back up. :app sees features; features see core and data; core and data never see features or :app; and no feature sees another feature. When this holds, the graph is acyclic, Gradle can parallelize it, and you can reason about any module in isolation.

Gradle gives you a precise tool to enforce the direction at compile time: the difference between api and implementation. An implementation dependency is kept off the compile classpath of anything that consumes your module; an api dependency is exposed to consumers along with its own transitive dependencies. Defaulting to implementation is what stops a dependency from quietly leaking three modules deep.

// core/network/build.gradle.kts
dependencies {
    // This type appears in the module's own public signatures,
    // so consumers must see it → api.
    api(project(":core:model"))

    // Retrofit and OkHttp are internal plumbing. Keep them off
    // every consumer's compile classpath → implementation.
    implementation(libs.retrofit)
    implementation(libs.okhttp)
}

Use api only when a type genuinely appears in your module's public API. Reach for it reflexively and you rebuild the leaky, everything-is-visible world that the old compile configuration created and that Gradle deprecated for exactly this reason.

How Clean Architecture layers map to Gradle modules

Google's app-architecture guide names three layers — presentation, domain, and data — and that is the vocabulary used here. The rule those layers obey comes from Robert C. Martin's Clean Architecture: source-level dependencies must point inward, toward the domain. That is dependency inversion, and it is the one idea this whole layout applies. The elegant part of modularization is that these layers become physical modules, and the compiler enforces that dependency rule the architecture otherwise only asks for politely.

  • Presentation lives in :feature:*. ViewModels, UI state, composables.
  • Domain is a pure-Kotlin module, :core:domain, with no Android dependency at all. It holds use cases, entities, and, crucially, the repository interfaces.
  • Data lives in :core:data or dedicated :data:* modules. It holds the repository implementations and data sources.

The non-obvious move is the direction of the dependency between domain and data. The domain defines the contract; the data layer implements it; so at the Gradle level, data depends on domain, not the reverse. This is dependency inversion, and it is the reason the domain can stay a clean, framework-free module that is trivial to unit-test.

// :core:domain — pure Kotlin. No Android, no Retrofit, no Room.
data class Article(
    val id: String,
    val title: String,
    val publishedAt: Instant,
)

interface ArticleRepository {
    fun observeArticles(): Flow<List<Article>>
    suspend fun refresh()
}
// :core:data — depends on :core:domain and implements its contract.
internal class DefaultArticleRepository @Inject constructor(
    private val remote: ArticleRemoteDataSource,
    private val local: ArticleDao,
) : ArticleRepository {

    override fun observeArticles(): Flow<List<Article>> =
        local.observeAll().map { entities -> entities.map(ArticleEntity::toDomain) }

    override suspend fun refresh() {
        val network = remote.fetchArticles()
        local.upsert(network.map(NetworkArticle::toEntity))
    }
}

The feature's ViewModel now depends only on the ArticleRepository interface from :core:domain. It has no idea Retrofit or Room exist, which keeps the whole feature testable with a fake repository. If you want the deeper argument for designing use cases as plain suspend functions and keeping scope and dispatcher management in the upper layers, I made it in detail in my write-up on moving from RxJava to Kotlin Coroutines.

// :feature:articles — presentation layer, sees only the domain contract.
@HiltViewModel
class ArticlesViewModel @Inject constructor(
    repository: ArticleRepository,
) : ViewModel() {

    val uiState: StateFlow<ArticlesUiState> =
        repository.observeArticles()
            .map { articles -> ArticlesUiState.Success(articles.map(Article::toUiModel)) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = ArticlesUiState.Loading,
            )
}

Convention plugins: writing build logic once

Ten modules means ten build.gradle.kts files, and without discipline they drift: one sets a different compileSdk, another forgets to apply the Compose plugin. Convention plugins solve this by capturing a module's shared configuration in one reusable place.

The pattern popularized by Google's Now in Android sample is a build-logic included build of small, composable plugins, each doing one thing. Deliberately not buildSrc: changing anything in buildSrc invalidates the entire build cache, whereas an included build does not. Each plugin is additive and single-responsibility, so a module picks only the ones it needs.

// build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) = with(target) {
        pluginManager.apply("myapp.android.library")
        pluginManager.apply("myapp.android.compose")

        dependencies {
            add("implementation", project(":core:designsystem"))
            add("implementation", project(":core:domain"))
        }
    }
}

A feature module's build file then shrinks to its genuinely unique parts:

// feature/articles/build.gradle.kts
plugins {
    id("myapp.android.feature")
}

android {
    namespace = "com.example.feature.articles"
}

Every feature module now gets the same SDK versions, Compose setup, and baseline dependencies for free, and you change them in exactly one file. One-off logic that only one module needs still belongs directly in that module's build file, not smuggled into a shared plugin.

Common mistakes

Three failure modes show up again and again, and all three are easier to prevent than to untangle later.

The god :core module

The most common one. A single :core (or :common, or :utils) module starts as a convenient dumping ground and swells until every module depends on it and it depends on half of them. Now a change to one unrelated helper recompiles the entire app, erasing the build-time benefit you modularized for. Prevent it by splitting core along real seams from day one: :core:ui, :core:network, :core:common, :core:model. Many small, focused core modules beat one that knows everything.

Cyclic dependencies

A depends on B, B depends on A, directly or through a chain. Gradle will refuse to build it, which is the good news; the bad news is the fix is usually a design problem, not a build-file edit. A cycle almost always means a boundary is in the wrong place. Extract the shared piece into a module both can depend on, or invert one of the dependencies with an interface, as in the domain/data split above.

Leaking data models into the UI

The subtlest one. A @Serializable network DTO or a Room @Entity is convenient, so it gets passed straight up to the ViewModel and into a composable. Now your UI is coupled to a wire format: rename a JSON field and a screen breaks. Each layer should own a model shaped for its own job and map at the boundary.

// :feature:articles — a UI-shaped model, distinct from the data layer's types.
data class ArticleUiModel(
    val id: String,
    val title: String,
    val relativeTime: String,
)

internal fun Article.toUiModel(): ArticleUiModel =
    ArticleUiModel(
        id = id,
        title = title,
        relativeTime = publishedAt.toRelativeTime(),
    )

The mapping functions feel like boilerplate right up until the day the API changes and the blast radius stops at one file.

Where to start

If you are staring at a single-module app, do not try to shatter it into twenty modules over a weekend. Pull out :core:designsystem and :core:model first, because almost everything depends on them and nothing depends back. Then carve off one well-understood feature into :feature:* and watch the dependency arrows. Add convention plugins once you have two or three modules repeating the same build configuration.

The module graph is a living constraint, not a finished drawing. Every arrow you let point the wrong way is a boundary you will pay to rebuild later, so the cheapest time to get the direction right is the first time.

WORK TOGETHER

Let’s start with a good problem.

Looking for an Android engineer for your team, or someone to think through a technical problem with? Let’s talk.

Book a 30-minute conversation[email protected]All contact and podcast links