DEV Community

Cover image for 🧱 Breaking the Monolith: A Practical, Step-by-Step Guide to Modularizing Your Android Appā€Š-ā€ŠPartĀ 1
Vortana Say
Vortana Say

Posted on

🧱 Breaking the Monolith: A Practical, Step-by-Step Guide to Modularizing Your Android Appā€Š-ā€ŠPartĀ 1

This article was originally published on Medium. You can read it here.

In the "Untangling Android Navigation"Ā seriesĀ (Starter GitHub code), we built a healthy, single-module app using Jetpack Compose, Hilt, Paging, nested navigation, and deep links. That’s great for learningā€Šā€”ā€Šbut large production apps eventually need modularization to scale builds, teams, and features.

In part 1 of this article, we are going to focus on

  • The blueprint: a clear thought process, a high-level plan, and a safe step-by-step migration order so you (and your team) can modularize with confidence

  • Implement convention plugins to manage Gradle build logic

  • Implement feature_bookmarks module


Why Modularize?

Benefits:

  • ⚔ Build performance → Recompile only what changed; faster local builds & CI.

  • šŸ‘„ Team velocity → Parallelize work; isolate feature ownership; fewer merge conflicts.

  • šŸ› Architecture enforcement → Keep boundaries clean between UI, domain, and data.

  • šŸ” Reusability → Shared design system, clients, and models become stable libraries.

  • 🧩 Future-proofing → One step away from Dynamic Feature Delivery.

When not to modularize: minimal apps, solo projects, or prototypes where overhead > benefit.


Starting Point: TheĀ Monolith

Our current single module looks like this:

:app/
ā”œā”€ā”€ build.gradle.kts (Module-level Gradle file for :app)
ā”œā”€ā”€ src/
│   ā”œā”€ā”€ androidTest/
│   │   └── java/
│   │       └── com/vsay/pintereststylegriddemo/
│   │           └── (Instrumented tests, e.g., for UI or navigation)
│   ā”œā”€ā”€ main/
│   │   ā”œā”€ā”€ AndroidManifest.xml
│   │   ā”œā”€ā”€ java/
│   │   │   └── com/vsay/pintereststylegriddemo/
│   │   │       ā”œā”€ā”€ MainApplication.kt      (If using Hilt, @HiltAndroidApp)
│   │   │       ā”œā”€ā”€ MainActivity.kt         (Entry point, hosts AppWithTopBar)
│   │   │       │
│   │   │       ā”œā”€ā”€ common/                 (Shared utilities, constants, and ALL navigation before modularization)
│   │   │       │   ā”œā”€ā”€ navigation/
│   │   │       │   │   ā”œā”€ā”€ AppRoute.kt
│   │   │       │   │   ā”œā”€ā”€ AppNavHost.kt
│   │   │       │   │   ā”œā”€ā”€ BottomNavItem.kt
│   │   │       │   │   ā”œā”€ā”€ MainAppNavGraph.kt  (Defines Home, Detail composables for NavHost)
│   │   │       │   │   ā”œā”€ā”€ ProfileNavGraph.kt  (Defines Profile, Account Settings composables)
│   │   │       │   │   └── BookmarkNavGraph.kt (Defines Bookmark composables)
│   │   │       │   └── utils/
│   │   │       │       └── (General utility files)
│   │   │       │
│   │   │       ā”œā”€ā”€ di/                     (Dependency Injection - Hilt modules for the whole app)
│   │   │       │   ā”œā”€ā”€ AppModule.kt        (App-level bindings, context, etc.)
│   │   │       │   ā”œā”€ā”€ ViewModelModule.kt  (If providing ViewModels not directly with @HiltViewModel)
│   │   │       │   ā”œā”€ā”€ NetworkModule.kt    (Retrofit, OkHttp, ApiService bindings)
│   │   │       │   └── DatabaseModule.kt   (Room DB, DAO bindings)
│   │   │       │
│   │   │       ā”œā”€ā”€ domain/                 (Core business logic - models, repository interfaces, use cases)
│   │   │       │   ā”œā”€ā”€ model/
│   │   │       │   │   ā”œā”€ā”€ Image.kt
│   │   │       │   │   └── (User.kt - even if simplified for now)
│   │   │       │   ā”œā”€ā”€ repository/
│   │   │       │   │   └── ImageRepository.kt (Interface)
│   │   │       │   └── usecase/
│   │   │       │       └── GetImageDetailsUseCase.kt
│   │   │       │
│   │   │       ā”œā”€ā”€ data/                   (Data sources and repository implementations)
│   │   │       │   ā”œā”€ā”€ remote/
│   │   │       │   │   ā”œā”€ā”€ ApiService.kt
│   │   │       │   │   └── dto/
│   │   │       │   │       └── ImageDto.kt
│   │   │       │   ā”œā”€ā”€ local/              (If using Room for local persistence)
│   │   │       │   │   ā”œā”€ā”€ AppDatabase.kt
│   │   │       │   │   ā”œā”€ā”€ ImageDao.kt
│   │   │       │   │   └── ImageEntity.kt
│   │   │       │   ā”œā”€ā”€ repository/
│   │   │       │   │   └── ImageRepositoryImpl.kt
│   │   │       │   └── mapper/
│   │   │       │       └── (ImageMapper.kt - DTO to Domain)
│   │   │       │
│   │   │       ā”œā”€ā”€ presentation/           (UI logic: ViewModels and Screens for ALL features)
│   │   │       │   ā”œā”€ā”€ app/
│   │   │       │   │   └── AppViewModel.kt   (For global TopAppBarConfig, etc.)
│   │   │       │   │
│   │   │       │   ā”œā”€ā”€ common/             (Shared presentation elements like TopAppBarConfig class)
│   │   │       │   │   ā”œā”€ā”€ TopAppBarConfig.kt
│   │   │       │   │   └── NavigationIconType.kt
│   │   │       │   │
│   │   │       │   ā”œā”€ā”€ home/               (Logically for Home feature)
│   │   │       │   │   ā”œā”€ā”€ viewmodel/
│   │   │       │   │   │   └── HomeViewModel.kt
│   │   │       │   │   └── ui/
│   │   │       │   │       ā”œā”€ā”€ HomeScreen.kt
│   │   │       │   │       └── (HomeScreenUI.kt - if separated)
│   │   │       │   │
│   │   │       │   ā”œā”€ā”€ detail/             (Logically for Detail feature)
│   │   │       │   │   ā”œā”€ā”€ viewmodel/
│   │   │       │   │   │   └── DetailViewModel.kt
│   │   │       │   │   └── ui/
│   │   │       │   │       └── DetailScreen.kt (which contains DetailScreenUI)
│   │   │       │   │
│   │   │       │   ā”œā”€ā”€ profile/            (Logically for Profile feature)
│   │   │       │   │   ā”œā”€ā”€ viewmodel/
│   │   │       │   │   │   └── ProfileViewModel.kt
│   │   │       │   │   └── ui/
│   │   │       │   │       ā”œā”€ā”€ ProfileScreen.kt
│   │   │       │   │       ā”œā”€ā”€ ProfileScreenUI.kt
│   │   │       │   │       ā”œā”€ā”€ AccountSettingsOverviewScreen.kt
│   │   │       │   │       ā”œā”€ā”€ ChangePasswordScreen.kt
│   │   │       │   │       └── (EditProfileScreen.kt, ManageNotificationsScreen.kt)
│   │   │       │   │
│   │   │       │   └── bookmark/           (Logically for Bookmark feature)
│   │   │       │       ā”œā”€ā”€ viewmodel/
│   │   │       │       │   └── BookmarkViewModel.kt
│   │   │       │       └── ui/
│   │   │       │           ā”œā”€ā”€ BookmarkScreen.kt
│   │   │       │           └── (BookmarkScreenUI.kt - if separated)
│   │   │       │
│   │   │       └── ui/                     (Overall App UI structure and Theme)
│   │   │           ā”œā”€ā”€ AppWithTopBar.kt    (Main Scaffold, TopAppBar, BottomNav, NavHost integration)
│   │   │           ā”œā”€ā”€ common/             (Shared UI components not specific to a screen, e.g. GenericLoading.kt)
│   │   │           └── theme/
│   │   │               ā”œā”€ā”€ Color.kt
│   │   │               ā”œā”€ā”€ Theme.kt
│   │   │               ā”œā”€ā”€ Type.kt
│   │   │               └── Shape.kt
│   │   │
│   │   └── res/                          (All resources are currently in :app)
│   │       ā”œā”€ā”€ drawable/
│   │       ā”œā”€ā”€ layout/ (Minimal, maybe activity_main.xml)
│   │       ā”œā”€ā”€ mipmap-xxxhdpi/ (App icons)
│   │       ā”œā”€ā”€ values/
│   │       │   ā”œā”€ā”€ colors.xml
│   │       │   ā”œā”€ā”€ strings.xml (Contains ALL strings for all features)
│   │       │   └── themes.xml
│   │       └── (Other resource folders like font/, raw/)
│   │
│   └── test/
│       └── java/
│           └── com/vsay/pintereststylegriddemo/
│               └── (Unit tests for ViewModels, UseCases, Mappers, etc.)
│
ā”œā”€ā”€ .gitignore
ā”œā”€ā”€ build.gradle.kts (Project-level)
ā”œā”€ā”€ settings.gradle.kts (Declares only ':app' module)
└── gradle.properties
Enter fullscreen mode Exit fullscreen mode

This works, but everything depends onĀ :app. A small change in one area tends to rebuild the world.

The Target: A Pragmatic ModuleĀ Map

We’ll aim for this:

:app                       // entry point (Activity, AppNavHost, bottom bar, @HiltAndroidApp)
:core-domain               // models, use cases, repository interfaces (pure Kotlin)
:core-data                 // Retrofit/Room, DTOs, mappers, repo impls (Hilt modules for data)
:core-ui                   // theme, typography, shared Compose components
:core-navigation           // route contracts, deep link constants
:core-common               // (NEW) generic utils, base classes, non-UI/domain constants

:feature-home              // UI + VM + nav graph definition
:feature-detail            // UI + VM + nav graph definition
:feature-profile           // UI + VM + nav graph definition (incl. nested graphs)
:feature-bookmark          // UI + VM + nav graph definition

// Optional, but highly recommended for larger projects:
:core-testing              // (NEW - for shared test utilities)
Enter fullscreen mode Exit fullscreen mode
  • Principle: Features depend inward (onĀ :core-*), never sideways on each other. :app depends on all features and stitches them together.

  • DI (Hilt) does not need its own dedicated module.

    • Hilt modules (@Module annotated classes) should reside in the Gradle module where the dependencies they provide are most logically located or implemented.
    • Creating a separateĀ :core-di module just for Hilt files can sometimes create an extra, somewhat artificial layer and might lead to complex dependency management for that DI module itself. Co-locating Hilt modules with the implementations or logical groupings they configure is usually cleaner.

🧭 Thought Process Before Touching Code

Before you move a single file, align on how modularization will work. Rushing into splitting modules without guardrails is the fastest way to chaos. These principles act like a compass: once agreed upon, the actual file moves become mechanical. Without them, modularization can easily spiral into refactors, regressions, or wasted effort.

Here’s a decision framework:

1. Stabilize the DomainĀ First

What it means:

Extract domain models (e.g., User, Image) and repository interfaces (ImageRepository, UserRepository) intoĀ :core-domain. Keep it pure Kotlin with no Android dependencies.

Why first?

  • Stable contracts → Every feature and data layer depends on domain types. If you change them mid-migration, the ripple breaks everything.

  • Build speed → Pure Kotlin modules compile fastest. Locking them down means most changes won’t invalidate them.

  • Testability → With the domain isolated, you can write fast, JVM-only tests for your most critical business rules.

šŸ‘‰ Reasoning: Think of the domain as the ā€œpublic APIā€ of your app’s business logic. Stabilizing it first is like pouring the foundation of a house before moving walls around.

2. Extract the Most Reused ThingsĀ Next

What it means:

Pull out your design system (colors, typography, theming), reusable Compose UI components (e.g., AppTopBar, ErrorView), and navigation constants intoĀ :core-ui andĀ :core-navigation.

Why now?

  • High reuse → Nearly every feature imports them. Leaving them inĀ :app means features remain coupled to the monolith.

  • Prevents duplication → If you modularize features first, you’ll end up copy-pasting components before you extract them.

  • Build savings → UI components change often; isolating them means recompiles don’t cascade into unrelated modules.

šŸ‘‰ Reasoning: Shared UI and route contracts are like the ā€œshared languageā€ of the app. Extracting them early gives features a common dictionary to speak.

3. Choose a Safe Migration Order

What it means:

Ā Don’t move everything at once. Instead, peel off features in an order that reduces risk: Bookmark -> Detail → Profile → Home.

Why this order?

  • Bookmark → Simple dummy composable screen

  • Detail → A leaf feature (only consumes an ID). Few dependencies, minimal blast radius.

  • Profile → Demonstrates nested graphs (Profile → Settings → EditProfile), useful for testing navigation modularity.

  • Home → The hub feature (feeds, notifications, paging). Depends on many things, so save it for last.

šŸ‘‰ Reasoning: Start with low-risk, low-dependency features to validate your setup. Leave ā€œhubā€ features for last to avoid blocking progress elsewhere.

4. Keep Navigation Boundaries Clear

What it means:

Ā Each feature defines its own NavGraph extension, but route constants live inĀ :core-navigation.Ā :app is the only place where everything is stitched together.

Why this way?

  • Encapsulation → Features don’t need to know each other’s internals, only shared contracts.

  • Flexibility → Easier to swap features in/out (e.g., AB testing, dynamic delivery).

  • Correctness → Avoids fragile ā€œstringly typedā€ navigation mistakes by centralizing route contracts.

šŸ‘‰ Reasoning: Navigation is the glue, not a feature. Keeping boundaries clear means your app scales like LEGO bricksā€Šā€”ā€Šeach feature is self-contained and reusable.

5. Prepare Dependency Injection (DI) Boundaries

What it means:

  • Interfaces (contracts) inĀ :core-domain.

  • Implementations + Hilt bindings inĀ :core-data.

  • Feature modules depend only on interfaces.

Why this pattern?

  • No circular dependencies → :core-domain doesn’t depend on anything.

  • Feature isolation → Features inject only what they need (interfaces), not whole implementations.

  • Flexibility → Easy to swap implementations (network vs fake, DB vs in-memory) for testing or experiments.

šŸ‘‰ Reasoning: This is textbook Dependency Inversion. By making features depend on abstractions, you lock down clean boundaries and gain long-term maintainability.


šŸ›  Pre-Work Checklist Before Migration

Before you split a single module, set up these guardrails. Skipping them leads to config drift, resource conflicts, and painful rewrites later. Also, before creating new modules, ensure these are in place in your current single-module project. If they are, great! If not, now is the time to set them up.

Version Catalog (libs.versions.toml)

  • Action: Create/update gradle/libs.versions.toml to centralize all dependency versions (Compose, Kotlin, Hilt, Retrofit, Room, etc.).

  • Reason: Centralize dependency versions (Compose BOM, Retrofit, Room) and ensure version consistency across all future modules and simplifying updates. Prevents version conflicts when multiple modules declare the same dependency.

toml
[versions]
kotlin = "1.9.22"
composeCompiler = "1.5.8"
composeBom = "2024.02.02"
# ... other versions

[libraries]
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
# ... other libraries

[plugins]
# ... gradle plugins
Enter fullscreen mode Exit fullscreen mode

Convention Plugins or buildSrc

  • Extract common Gradle configs (compileSdk, Compose flags, Kotlin options).

  • Ensures consistency and reduces boilerplate when adding new modules.

  • Reason: Reduces boilerplate in each module’s build.gradle.kts, enforces consistency, makes global configuration changes easier, also ensures consistency and reduces boilerplate when adding new modules.

Resource Hygiene

  • Prefix shared resources (core_ for colors, strings, drawables).

  • Prevents namespace collisions once you have multiple feature resource folders.

Dependency Scoping Rules

  • Default to implementation. Use api Only if consumers must see the type.

  • Enforces proper encapsulation across modules.

Build Performance Optimizations

  • Prefer KSP over KAPT (e.g., for Room, Moshi).

  • Enable Gradle build cache + configuration cache.

Test Strategy Setup

  • Decide where tests live (inside each module).

  • Set up CI to run only affected modules’ tests on PRs.

šŸ‘‰ Do these first → then the migration becomes smooth, mechanical, and predictable.


šŸ’»āš™ļøšŸ”§ Convention Plugins orĀ buildSrc

There are two options to manage Gradle build logic, convention plugins or buildSrc. We are going to choose Convention plugins due to the reasons below:

Convention Plugin:

  • Convention Plugins are the tools that help you manage the build configurations of these modules consistently and efficiently.

  • It’s a piece of code (usually written in Kotlin or Groovy) that applies a set of pre-defined configurations to a Gradle project (a module).

  • For example, you could have an android-library-convention.gradle.kts plugin that sets up everything a typical Android library module needs.

  • Included Build: This is different from buildSrc, which is a special directory Gradle handles automatically. An included build is more explicit and can be more flexible.

Pros:

  • āœ… Decoupled:build-logic is an independent build, not tied to the main project classpath. Changes don’t trigger full recompiles like buildSrc does.

  • āœ… Clear Separation of Concerns: Build logic is neatly separated into its own convention-plugins build, away from your application/library source code. This is often cleaner than buildSrc.

  • āœ… Avoids buildSrc Quirks: buildSrc has a special classpath that can sometimes lead to subtle issues or conflicts. A regular included build often has a cleaner, more isolated classpath.

  • āœ… Testability (Advanced): Convention plugins in an included build are easier to test with unit tests than logic directly in buildSrc.

  • āœ… Scalable: Easy to split into multiple convention plugins (Android app, library, feature, Compose, testing, etc.).

  • āœ… Recommended by Google: AndroidX and Nowinandroid use convention plugins for large-scale modular projects.

Cons:

  • Slightly more setup complexity than buildSrc.

Implement Convention Plugin

Step 1: Create the Directory for the Plugins Build. At the root of your PinterestStyleGridDemo project, create a new directory: convention-plugins

Step 2: Initialize the convention-plugins Build

  • Create convention-plugins/settings.gradle.kts:
rootProject.name = "pinterest-convention-plugins"

dependencyResolutionManagement {
    versionCatalogs {
        create("libs") { // creating a catalog named "libs" for this included build
            from(files("../gradle/libs.versions.toml")) // Pointing to the TOML file in the root project
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Create convention-plugins/build.gradle.kts:
import org.gradle.kotlin.dsl.`kotlin-dsl`

plugins {
    `kotlin-dsl`
}

repositories {
    google()
    mavenCentral()
}

// Access versions from the catalog for these dependencies
dependencies {
    implementation("com.android.tools.build:gradle:${libs.versions.agp.get()}") // agp version is in the catalog
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}") // kotlin version is in the catalog
}

gradlePlugin {
    plugins {
        register("androidLibrary") {
            id = "pinterest.android-library-convention"
            implementationClass = "AndroidLibraryConventionPlugin"
        }
        // Register other plugins here
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: add convention-plugins to root settings.gradle.kts

// other codes
rootProject.name = "PinterestStyleGridDemo"
include(":app")
includeBuild("convention-plugins")
Enter fullscreen mode Exit fullscreen mode

Sync the Project with Gradle files, and the build should be successful.

Implement Custom GradleĀ plugin

Think about what most of your Android library modules (that aren’t the mainĀ :app module) will need:

Core Android Library Setup:

  • They’ll all apply the com.android.library plugin.

  • They’ll all apply the org.jetbrains.kotlin.android plugin.

  • They’ll need a compileSdk, minSdk, and targetSdk.

  • They’ll need a testInstrumentationRunner.

  • They’ll need standard JavaVersion compatibility for compileOptions.

Common Dependencies:

  • Kotlin standard library (org.jetbrains.kotlin:kotlin-stdlib).

  • Basic testing libraries (JUnit, AndroidX Test, Espresso).

  • Perhaps AndroidX Core KTX for useful extension functions.

Build Optimizations/Configurations:

  • Maybe specific ProGuard rules for release builds (though often centralized).

  • Lint configurations.

  • Code coverage (JaCoCo) setup

Instead of writing this boilerplate in every single library module’s build.gradle.kts file, you create an AndroidLibraryConventionPlugin that encapsulates all the common setup listed above.

Why we need it:

  • DRY (Don’t Repeat Yourself): Write the setup once.

  • Consistency: Every library module gets the same battle-tested configuration. No accidental variations in minSdk or forgotten test dependencies.

  • Maintainability: Need to update compileSdk for all libraries? Change it in ONE place (the convention plugin).

  • Readability: Your library module’s build.gradle.kts becomes tiny, only declaring plugins and dependencies specific to that module.

How to define what and how many custom Gradle plugins need to be created?

These are the processes that I found useful and produce good results:

  1. Identify and Isolate a Pilot Feature Module First: You need a concrete example of a module to understand its true, common needs. Creating convention plugins in a vacuum can lead to premature abstraction or missing essential configurations. By extracting a real feature, you’ll see exactly what boilerplate you’re repeating.

  2. Observe the Boilerplate in the new Module’s build.gradle.kt in the feature module: Once we create and move files to feature module then we can see what are the boilerplate code used in this build.gradle.kts.

  3. Create Convention Plugin file(s) for the feature module

  4. Apply the Convention Plugin to the Feature Module to see the benefit immediately and verify the plugin works.

  5. Iterate and Expand: As you extract more features or common libraries, you’ll refine your existing convention plugins and create new ones.

Why this order?

  • Concrete over Abstract: It’s easier to generalize from a concrete example than to correctly guess all common needs upfront.

  • Immediate Value: You see the benefit of your convention plugin right away with the first new module.

  • Less Rework: If you create plugins first, you might find they don’t perfectly fit the needs of your actual modules once you start creating them, leading to more refactoring of the plugins.

  • Incremental Progress: Modularization can be a large task. This approach allows you to do it piece by piece, validating each step.


Implement feature_bookmarks module

Create theĀ :feature-bookmarks Android Library Module in the project

  • Folder/Module name → feature-bookmarks

  • In Gradle configuration, → must prefix withĀ :.

  • Gradle usesĀ : as a path separator for projects.Ā : is not part of the module’s actual name, but rather part of the Gradle project path. Examples:

    : = root project

    :feature-bookmarks = module named feature-bookmarks at the root

    :features:bookmarks = module bookmarks inside folder/module features

Move Bookmarks-Specific Code from app module to feature-bookmarks

  • In BookmarkScreen.kt, it is trying to access code that’s still in theĀ :app module. We can addĀ :app module to theĀ :feature-module, but this creates a circular dependency which we want to avoid. So there are codes we need to refactor:

    • Change import R file from com.vsay.pintereststylegriddemo.R to com.vsay.pintereststylegriddemo.feature.bookmarks.R and create string resources used in the compose screen
    • Decoupling AppViewModel, TopAppBarConfig, NavigationIconType. The current: the current BookmarkScreen takes appViewModel to configure the top app bar. This creates a dependency onĀ :app. We need to invert this: BookmarkScreen should describe its app bar needs, and the caller (inĀ :app) should fulfill them.
@Composable
fun BookmarkScreen() {
    BookmarkScreenUI(modifier = Modifier)
}

@Composable
fun BookmarkScreenUI(modifier: Modifier) {
    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = androidx.compose.ui.platform.LocalContext.current.getString(R.string.bookmark_screen_title),
            style = MaterialTheme.typography.headlineMedium
        )
        Text(
            text = androidx.compose.ui.platform.LocalContext.current.getString(R.string.bookmark_screen_placeholder_text),
            style = MaterialTheme.typography.bodyLarge
        )
    }
}
Enter fullscreen mode Exit fullscreen mode
  • In BookmarkNavGraph.kt: it previously took appViewModel. It no longer needs to. It just defines the composable for the bookmarks screen route.
// Define a route for this feature's graph, if it has multiple screens.
// If it's just one screen, this might not be a nested graph.
const val BOOKMARKS_GRAPH_ROUTE = "bookmarks_graph"
const val BOOKMARK_SCREEN_ROUTE = "bookmark_screen_route"

fun NavGraphBuilder.bookmarkNavGraph() {
    // If bookmarks feature is just one screen, you can use composable directly.
    // If it's a nested graph of multiple bookmark-related screens:
    navigation(
        startDestination = BOOKMARK_SCREEN_ROUTE,
        route = BOOKMARKS_GRAPH_ROUTE
    ) {
        composable(route = BOOKMARK_SCREEN_ROUTE) {
            BookmarkScreen()
        }
        // Add other composables specific to the bookmarks feature here if any
    }
}
Enter fullscreen mode Exit fullscreen mode

Observe the Boilerplate in build.gradle.kts underĀ :feature-bookmakrs module

Here is our build.gradle.kts(:feature-bookmars)

@Suppress("DSL_SCOPE_VIOLATION") // Remove if not needed after plugins are applied
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "com.vsay.pintereststylegriddemo.feature.bookmarks"
    compileSdk = libs.versions.compileSdk.get().toInt()

    defaultConfig {
        minSdk = libs.versions.minSdk.get().toInt()
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles("consumer-rules.pro")
    }

    buildTypes {
        release {
            isMinifyEnabled = false // Adjust as needed for your project
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = libs.versions.jvmTarget.get()
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.activity.compose)

    // material
    implementation(libs.material)
    implementation(libs.androidx.material3)

    // Jetpack Compose
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.navigation.compose) // For NavHostController, composable, etc.
}
Enter fullscreen mode Exit fullscreen mode

Notice there are a bunch of configurations in android {…} and dependencies {…}; we know that in other feature modules, they also need to config information for android {….} and dependencies {…}, so these are candidates that can be moved to the convention plugin

Create Convention Plugin

We’ll create a plugin (e.g., AndroidLibraryConventionPlugin.kt) in our convention-plugins build src/main/kotlin package. This plugin will encapsulate all the common setup currently inĀ :feature_bookmarks/build.gradle.kts. Specifically, it will:

  1. Apply the necessary base Gradle plugins (com.android.library, org.jetbrains.kotlin.android, org.jetbrains.kotlin.plugin.compose).

  2. Configure the Android extension with standard settings (SDK versions, minSdk, Java compatibility, buildFeatures { compose = true }, composeOptions, default Proguard rules if any).

  3. Add common dependencies (like Kotlin stdlib, core AndroidX libraries, Jetpack Compose libraries, common testing libraries).

import com.android.build.gradle.LibraryExtension
import org.gradle.api.JavaVersion
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
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile // Added import
import org.jetbrains.kotlin.gradle.dsl.JvmTarget // Added import

class AndroidLibraryConventionPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.run {
            // Get the version catalog named "libs"
            val libs = extensions.getByType<VersionCatalogsExtension>()
                .named("libs")

            plugins.apply("com.android.library")
            plugins.apply("org.jetbrains.kotlin.android")

            extensions.configure<LibraryExtension> {
                // 'libs' is now correctly typed as VersionCatalog and available from above

                compileSdk = libs.findVersion("compileSdk").get().requiredVersion.toInt()
                defaultConfig {
                    minSdk = libs.findVersion("minSdk").get().requiredVersion.toInt()
                    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
                    consumerProguardFiles("consumer-rules.pro")
                }

                buildTypes {
                    release {
                        isMinifyEnabled = false
                        proguardFiles(
                            getDefaultProguardFile("proguard-android-optimize.txt"),
                            "proguard-rules.pro"
                        )
                    }
                }

                // Get Java version from libs
                val javaVersionFromToml = libs.findVersion("java").get().requiredVersion // e.g., "1.8", "11"
                val javaVersionForCompileOptions = JavaVersion.toVersion(javaVersionFromToml)
                compileOptions {
                    sourceCompatibility = javaVersionForCompileOptions
                    targetCompatibility = javaVersionForCompileOptions
                }

                buildFeatures {
                    compose = true
                }

                composeOptions {
                    kotlinCompilerExtensionVersion =
                        libs.findVersion("composeCompiler").get().requiredVersion
                }

                lint {
                    abortOnError = true // Fail the build on lint errors
                    warningsAsErrors = true
                    checkDependencies = true
                }
            }

            // Configure Kotlin JVM toolchain (JDK for compilation)
            project.extensions.getByType(KotlinAndroidProjectExtension::class.java).jvmToolchain(
                libs.findVersion("jvmTarget").get().requiredVersion.toInt() // e.g., jvmTarget = "8" or "11" in TOML -> results in 8 or 11
            )

            // Configure Kotlin compiler options (bytecode target)
            project.tasks.withType(KotlinCompile::class.java).configureEach {
                compilerOptions {
                    val javaVersionFromTomlForKotlin = libs.findVersion("java").get().requiredVersion // e.g., "1.8", "11"
                    jvmTarget.set(JvmTarget.fromTarget(javaVersionFromTomlForKotlin))
                }
            }

            // Common Dependencies
            dependencies {
                add("implementation", libs.findLibrary("androidx-core-ktx").get())
                add("implementation", libs.findLibrary("androidx-activity-compose").get())

                add("implementation", libs.findLibrary("material").get())
                add("implementation", libs.findLibrary("androidx-material3").get())


                add("implementation", libs.findLibrary("androidx-compose-ui").get())
                add("implementation", libs.findLibrary("androidx-compose-ui-tooling").get())
                add("implementation", libs.findLibrary("androidx-compose-ui-tooling-preview").get())
                add("implementation", libs.findLibrary("androidx-navigation-compose").get())
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In conention-plugins/build.gradle.kts, we register AndroidLibraryConventionPlugin

// other codes

gradlePlugin {
    plugins {
        register("androidLibrary") {
            id = "pinterest.android-library-convention"
            implementationClass = "AndroidLibraryConventionPlugin"
        }
        // Register other plugins here
    }
}
Enter fullscreen mode Exit fullscreen mode

Apply the Convention Plugin toĀ :feature_bookmarks

In feature_bookmarks/build.gradle.kts, we can simplify it to just

plugins {
    // Apply your convention plugin using the ID you registered
    id("pinterest.android-library-convention")
    alias(libs.plugins.kotlin.compose)
}

android {
    // The namespace is always specific to the module and must remain here.
    namespace = "com.vsay.pintereststylegriddemo.feature.bookmarks"
}

dependencies {
    // Only dependencies that are SPECIFIC to the feature_bookmarks module
    // and are NOT already included by your "pinterest.android-library-convention" convention plugin.
}
Enter fullscreen mode Exit fullscreen mode

Sync the Gradle files and run the application. The app should run, and you should be able to navigate to the bookmarks screen without any issue.

Flow and interaction between modules at thisĀ stage

Module interaction

How to Interpret This Graph:

  1. Build Logic & Configuration:

    • This section shows your AndroidLibraryConventionPlugin.kt (which defines how library modules should be built) and the libs.versions.toml (which provides the versions and dependency coordinates).
    • The convention plugin reads from the version catalog to get consistent dependency versions and other settings.
  2. Module Build Definition:

    • feature_bookmarks/build.gradle.kts applies the convention plugin. This means the rules and configurations from AndroidLibraryConventionPlugin.kt are used to set up theĀ :feature_bookmarks module.
    • app/build.gradle.kts would have its own configurations (potentially using an app-specific convention plugin if you create one, or applying Android application plugins directly).
    • These Gradle files define how their respective module code (feature_bookmarks module code, app module code) should be processed.
  3. Compilation & Packaging:

    • During the Gradle build, theĀ :feature_bookmarks module code is compiled into anĀ .aar (Android Archive) library artifact.
    • TheĀ :app module code is compiled, and it depends on and includes theĀ :feature_bookmarks.aar.
    • Finally, the app module is packaged into anĀ .apk (Android Package) file that can be installed on a device.
  4. Application Runtime Flow:

    • The User interacts with the application.
    • The App Runtime (code running from yourĀ :app module) handles the main UI, navigation, and overall application flow.
    • When the user wants to access bookmarks, the App Runtime navigates to (or invokes) the Bookmarks Feature Runtime (code running from yourĀ :feature_bookmarks module).
    • The Bookmarks Feature displays its UI and provides its specific functionality to the user.
    • The dashed arrow from app_artifact to feature_bookmarks_artifact in this subgraph emphasizes that the compiled code from the feature module is part of, and executed within, the context of the application.

šŸ’” Takeaways

  • Successful Feature Module Creation: We’ve isolated the ā€˜bookmarks’ functionality into its own dedicated library module (:feature_bookmarks). This is the foundational step in modularizing your application, allowing for better separation of concerns.

  • Centralized Build Logic with Convention Plugins: We established a convention-plugins module (specifically AndroidLibraryConventionPlugin.kt) to define and apply common build configurations (Android library settings, Kotlin options, Java compatibility, common dependencies) across library modules.

  • Standardized Dependency Management: We’ve consistently used the Gradle Version Catalog (libs.versions.toml) within the convention plugin to manage dependency versions and plugin aliases. This ensures consistency and makes version updates much easier.

  • Refined Build Scripts: Both the feature_bookmarks/build.gradle.kts and the convention plugin’s build scripts have been refined to be cleaner, more maintainable, and leverage modern Gradle practices.

  • Modern Kotlin & Compose Setup: We addressed requirements for Kotlin 2.0+, including the explicit application of the Jetpack Compose Compiler plugin and correctly configuring Kotlin’s jvmTarget using the compilerOptions DSL.

  • Follow the process for effective results: Isolate -> Configure Manually -> Identify Pattern -> Create Convention Plugin -> Apply Plugin -> Repeat.

For the complete implementation and to explore the code in context, you can find the full project on GitHub: PinterestStyleGridDemo Repository

Follow me on Medium for more Android content.

Top comments (0)