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
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)
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
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
. Useapi
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 likebuildSrc
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
}
}
}
- 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
}
}
Step 3: add convention-plugins to root settings.gradle.kts
// other codes
rootProject.name = "PinterestStyleGridDemo"
include(":app")
includeBuild("convention-plugins")
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:
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.
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.
Create Convention Plugin file(s) for the feature module
Apply the Convention Plugin to the Feature Module to see the benefit immediately and verify the plugin works.
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 namedfeature-bookmarks
at the root
:features:bookmarks
= modulebookmarks
inside folder/modulefeatures
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
)
}
}
- 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
}
}
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.
}
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:
Apply the necessary base Gradle plugins (com.android.library, org.jetbrains.kotlin.android, org.jetbrains.kotlin.plugin.compose).
Configure the Android extension with standard settings (SDK versions, minSdk, Java compatibility, buildFeatures { compose = true }, composeOptions, default Proguard rules if any).
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())
}
}
}
}
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
}
}
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.
}
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
How to Interpret This Graph:
-
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.
-
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.
-
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.
-
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)