Mastering Android Multi-Module Architecture with Convention Plugins

Fatih Arslan
16 min readMay 25, 2024

--

Photo by Karla Hernandez on Unsplash

When building an Android app, whether a side project or a full-scale application, we typically start by placing all our code in the app module. As the project grows, so does the complexity of managing our codebase. We strive to keep things organized by creating separate folders for data, domain, and features, but despite our best efforts, the code can become unwieldy and difficult to navigate.

Design and architectural patterns can help manage complexity but don’t solve issues like increasing build times or sprawling build scripts. As projects expand, these challenges can quickly become overwhelming. In a team environment, a single-module approach means every part of the app is accessible to every developer, which may not be ideal.

Adopting a multi-module approach is an effective solution to these challenges. Each team member can concentrate on a specific feature or component by dividing the app into multiple modules. This division makes the development process more manageable and enhances feature isolation, leading to better code organization and quicker build times.

Regardless of the project structure, optimizations such as increasing RAM usage for the build daemon, optimizing cache, and updating the Android Gradle Plugin are beneficial. However, moving to a multi-module setup can offer significant advantages. It enhances code maintainability, simplifies updates and debugging, and facilitates the integration of new features or third-party services. Additionally, it can significantly reduce merge conflicts by isolating changes.

Indeed, if your project is relatively small — think 2 to 3 screens — and you don’t foresee any significant updates or expansions in the future, sticking with a single-module approach might be OK. This scenario is typical for projects used primarily as learning tools, which you might not revisit often.

However, I strongly recommend adopting a multi-module approach for almost any other type of development. It offers better scalability and manageability, making it essential for most projects.

I’ll also share some code samples to illustrate my points better. I encourage you not to copy and paste these directly into your projects. Take the time to understand the structure and the technologies involved thoroughly. I’ll also include a link to a starter project for your reference. Plus, you’ll find a project builder script that you can use. Instead of spending hours setting everything up manually, this script will have your project ready in just seconds!

What’s Inside This Article?

In this article, we will follow common modularization patterns (which are mentioned here) while crafting our project. I’ve chosen this strategy because it’s well-written and introduces thoughtful concepts. Plus, it’s likely something new team members will already be somewhat familiar with. We’ll also implement a version catalog to manage our dependencies and plugins from a central location efficiently. Additionally, we’ll leverage convention plugins to streamline our build logic. I’ll walk you through these concepts step by step. And we will use Jetpack Compose and type-safe navigation, which is newly introduced in (2.8.0-alpha08). Ultimately, you will get a highly scalable, maintainable, loosely coupled project that can easily extend its functionality. And if you want to check the more significant multi-module projects, I will attach them at the end.

Beginning with the Catalog

Photo by Erol Ahmed on Unsplash

Gradle introduced the version catalog in 7.0 release, which has an intuitive syntax, making it easier to grasp and offering improved performance over the older buildSrc method. However, you might still find some repositories using it, and even recent tutorials reference it. To understand why it’s no longer recommended, you might want to check out this blog post. Now, let’s explore what a version catalog looks like:

[versions]
androidxActivity = "1.9.0"
firebase-auth = "22.3.1"
firebase-firestore = "24.11.1"
hilt = "2.51.1"
kotlin = "1.9.23"
ksp = "1.9.23-1.0.20"
material3 = "1.2.1"
# ... Other versions

[libraries]
# Compose
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
# ... Other dependencies
# End of it

# Firebase
firebase-auth = { group = "com.google.firebase", name = "firebase-auth", version.ref = "firebase-auth" }
firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore", version.ref = "firebase-firestore" }
# ... Other dependencies
# End of it

# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
# ... Other dependencies
# End of it

[bundles]
compose = ["androidx-activity-compose", "androidx-compose-material3"]

[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

We typically use four main components to organize our dependencies in a version catalog. These are versions, libraries, bundles, and plugins. While you can technically arrange these components in any order, the sequence mentioned above is the most practical and commonly used. This order helps maintain clarity and ease of management in your build files. Let’s look at how these components are typically defined in build files:

implementation("androidx.activity:activity-ktx:1.9.0")

And, in the version catalog:

[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }

When defining libraries in a version catalog, we break down the dependency declaration into three key components: group, name, and version.ref. Here’s a closer look at each:

Group (group ID): This indicates the organization or group that maintains the library. It helps identify the source or the publisher of the library.

Name (artifact ID): This is the specific name of the library or component within the group. It specifies which particular piece of software you’re including in your project.

version.ref: This refers to a variable that represents the version of the library, which is declared in the versions section of the catalog. For instance, you might use a reference like “material3”. Alternatively, you can specify the version using the version parameter, such as version = “1.2.1".

It’s also helpful to organize and classify libraries with comments within the catalog. This practice helps keep things tidy, especially as the project grows and the file potentially becomes cluttered, making it challenging to locate specific libraries.

Bundles are another handy feature in version catalogs. They allow you to group similar dependencies, enabling you to add all related dependencies to your project in one go.

Finally, when defining plugins, the approach is similar to libraries, but instead of using group and name, you use id. This id serves as a unique identifier within the Gradle ecosystem, which Gradle uses to locate and apply the plugin to your project. Plugins differ from libraries in that they are not used during your application's compile and runtime phases. Instead, plugins primarily extend the build system, influencing how projects are built, tested, or deployed, but they do not become part of the application's runtime.

If you’re keen to dive deeper into the Gradle ecosystem and expand your understanding, starting with the official Gradle documentation is a great choice.

By setting up these elements and properly syncing your project, you can effectively manage and leverage your dependencies throughout the development lifecycle.

build.gradle.kts at:app Module

dependencies {
implementation(libs.androidx.activity.compose)
}

build.gradle.kts (Project Level)

plugins {
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}

These are the initial setups for our version catalog; we’ll refine and expand it as we go along. Now, let’s shift our focus to convention plugins.

Configuring Build Logic and Implementing Convention Plugins

Photo by Mourizal Zativa on Unsplash

A convention plugin in Gradle is designed to standardize and streamline the configuration process across your project by implementing a set of predefined settings or conventions. This approach helps to centralize and reduce the complexity of your build scripts. Instead of repeating configurations across multiple build modules, you can define these settings once in a convention plugin and apply them wherever needed. We first need to create a module specifically for our build logic.

Start by switching your project’s view from Android to Project in your IDE. The Project view provides a more comprehensive overview of all files, unlike the Android view, which focuses on the most relevant files and omits others like the .gradle folder. While it might seem unimportant, issues in your app can sometimes stem from problems with Gradle’s cache. In such cases, you can clear specific cache files or delete the entire .gradle folder to resolve issues. However, if you prefer a more streamlined view focusing on Android-specific files, sticking with the Android view is fine.

Now, let’s proceed with creating our build module. In your IDE, right-click on the project, go to the “New” menu, and select “Module.”

Creating a build-logic module from IDE

We entered :build-logic:convention in the module name field. This configuration will establish a build-logic folder and a module called convention within this. Here, we’ll house our convention plugins and any module-specific build scripts. Before we delve into the details of these plugins, let’s complete the initial setup for the build-logic module. Begin by creating a .gitignore, gradle.properties, and settings.gradle.kts files in the build-logic directory. You can look up examples for the .gitignore and gradle.properties files in the repository or customize them according to your needs. Next, we’ll explore the contents of the settings.gradle.kts file.

settings.gradle.kts at build-logic

dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}

rootProject.name = "build-logic"
include(":convention")

We registered our version catalog and included the convention module. We must update the project level settings.gradle.kts file. Doing so makes the build module visible to Gradle during the build process.

settings.gradle.kts (Project Level)

pluginManagement {
includeBuild("build-logic")
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}

rootProject.name = "Starter"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
include(":app")

In the pluginManager, we added our build logic. We eliminated the redundant convention module definition at the bottom, previously specified in the build logic. Additionally, we’ve activated type-safe project accessors. Here’s how you can include any module to your scripts:

implementation(projects.core.google)

We access the google module inside the core module in a type-safe manner. Next, let’s look into the build file inside the convention module.

build.gradle.kts at :build-logic:convention

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile  

plugins {
`kotlin-dsl`
}

group = "com.example.starter.build_logic.convention"

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}

dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.android.tools.common)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
}

tasks {
validatePlugins {
enableStricterValidation = true
failOnWarning = true
}
}

This Gradle script sets up the project for building conventions, specifying Java 17 as the source and target compatibility. It configures KotlinCompile tasks to target JVM 17 and adds compileOnly dependencies from a version catalog for several Gradle plugins, including Android and Kotlin plugins. Additionally, it enforces strict validation for plugins to ensure high code quality, with a validation task that fails on warnings. We will add another section to this script soon. Hopefully, you synced the project at that point, and everything is working fine. Let’s define the application convention plugin.

import com.android.build.api.dsl.ApplicationExtension
import com.espressodev.gptmap.configureKotlinAndroid
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure

class AndroidApplicationConventionPlugin: Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
}

extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
}
}
}
}

We utilized Kotlin DSL to create our plugin. This approach allows us to leverage Kotlin’s features while crafting the script entirely. Initially, we applied the necessary plugins and specified our target SDK. Subsequently, we passed the ApplicationExtension to our extension function, which we will explore in more detail next.

import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
compileSdk = 34

defaultConfig {
minSdk = 26
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}

configureKotlin()
}

private fun Project.configureKotlin() {
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
}
}

The configureKotlinAndroid function is designed to streamline the setup of standard configurations for the project. It sets the compiled SDK version to 34 and establishes the minimum SDK version as 26. It configures the project to support Java 8 features by setting up Java compatibility options and specifying Java 8 as the JVM target for Kotlin compilation.

A notable aspect of this function is its use of the commonExtension parameter, an instance of ApplicationExtension from a convention plugin. The CommonExtension interface includes several generic parameters representing configurations, such as build types and product flavors. By employing star projections (*), the function can handle any CommonExtension instance, regardless of the specific types used for these parameters. This flexibility makes applying the function across various Android project configurations easier without modification.

Also, if you’re using Gradle plugin version 8.2.x or below, reduce the star count to five.

Now, let’s look into one more convention plugin which we define for feature modules.

class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply {
apply("starter.android.library")
apply("starter.android.hilt")
apply("org.jetbrains.kotlin.plugin.serialization")
}

dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":core:common"))

// Define common dependencies for feature modules
implementation(libs.findLibrary("androidx-navigation-compose").get())
implementation(libs.findLibrary("kotlinx-serialization-json").get())
}
}
}
}

The AndroidFeatureConventionPlugin streamlines the setup of project by automatically configuring essential plugins and dependencies, specifically for Android libraries and Hilt dependency injection. This plugin ensures consistency across all feature modules by automatically including common core modules and external libraries, thus eliminating the need for manual configuration in each module.

For example, in a project that includes multiple feature modules such as “Login,” “UserProfile,” and “Dashboard,” each module often needs shared functionalities like analytics, or logging mechanisms. Instead of incorporating these dependencies into each module separately, the AndroidFeatureConventionPlugin can automatically include a core module like “common”. This setup allows every feature module to access the common module seamlessly. Any updates to a library within the core module are instantly reflected across all feature modules, ensuring uniformity and reducing the maintenance burden.

There are other convention plugins we need to define. However, I will skip them because they are similar to what we see already; for this, you can check the repository. Let’s take a look at this line.

implementation(libs.findLibrary("kotlinx-serialization-json").get())

We can’t directly use implementation and libs in this manner, but we can make the process more convenient by using extension functions.

import org.gradle.api.Project  
import org.gradle.api.artifacts.Dependency
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.kotlin.dsl.getByType

fun DependencyHandler.testImplementation(dependencyNotation: Any): Dependency? =
add("testImplementation", dependencyNotation)

fun DependencyHandler.testRuntimeOnly(dependencyNotation: Any): Dependency? =
add("testRuntimeOnly", dependencyNotation)

fun DependencyHandler.implementation(dependencyNotation: Any): Dependency? =
add("implementation", dependencyNotation)

fun DependencyHandler.androidTestImplementation(dependencyNotation: Any): Dependency? =
add("androidTestImplementation", dependencyNotation)

fun DependencyHandler.debugImplementation(dependencyNotation: Any): Dependency? =
add("debugImplementation", dependencyNotation)

val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")

Great! Now that we’ve created our convention plugins, the next step is registering them. Once registered, we can add them into our build scripts. Let’s go ahead and update our build file and version catalog accordingly.

build.gradle.kts at :build-logic:convention

/*...*/

gradlePlugin {
plugins {
register("androidApplicationCompose") {
id = "starter.android.application.compose"
implementationClass = "AndroidApplicationComposeConventionPlugin"
}
register("androidApplication") {
id = "starter.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
register("androidLibraryCompose") {
id = "starter.android.library.compose"
implementationClass = "AndroidLibraryComposeConventionPlugin"
}
register("androidLibrary") {
id = "starter.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidFeature") {
id = "starter.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
register("androidHilt") {
id = "starter.android.hilt"
implementationClass = "AndroidHiltConventionPlugin"
}
}
}

libs.versions.toml at .gradle

# ...

[plugins]
starter-android-application = { id = "starter.android.application", version = "unspecified" }
starter-android-application-compose = { id = "starter.android.application.compose", version = "unspecified" }
starter-android-library = { id = "starter.android.library", version = "unspecified" }
starter-android-library-compose = { id = "starter.android.library.compose", version = "unspecified" }
starter-android-feature = { id = "starter.android.feature", version = "unspecified" }
starter-android-hilt = { id = "starter.android.hilt", version = "unspecified" }

Initially, we register the plugins within the gradlePlugin block and list them in the plugins section of the version catalog. After completing these steps, we can sync the project to incorporate the latest updates. Once the synchronization is done, our convention plugins are prepared and available. There are more convention plugins that we need to outline, but I will omit the details here since they are akin to those we have already explored. Now, let’s move on to discuss the architecture.

Quick Overview of Architecture

Starter App Architecture

Our starter project has four top-level modules: app, build-logic, core, and feature. Additionally, there are sub-modules such as model under core and home under feature. The app module relies on two feature modules primarily for navigation purposes. It’s important to note that navigation setups can vary widely depending on specific project needs. There are numerous ways to configure navigation within your app, each with its trade-offs. Finding the right approach will depend on balancing these trade-offs with your specific requirements. We’ll explore how we manage navigation later on.

We’ve touched on navigation, so let’s shift our focus to another crucial aspect of multi-module architecture: minimizing coupling. You might be familiar with the concept expressed as “High cohesion, low coupling.” Coupling occurs when one module depends on another; for instance, the home module might rely on the data module to access specific repositories. This coupling is normal for the module and necessary to function correctly. However, without careful management from the beginning, it’s easy to end up with a highly coupled project. Let’s explore how we can effectively reduce coupling at the project level.

Our project aims to minimize the connections between modules to achieve low coupling. Additionally, by breaking the project into multiple modules, Gradle can leverage caching and parallel processing, which enhances build speed. In our setup, feature modules are independent of each other. This raises the question of how we manage navigation or data transfer between them. We centralize our navigation logic within the app module. This way, the two modules do not need to know specifics about each other. We also implement app-level navigation logic to streamline navigation. This approach is a practical example of how to reduce coupling between modules. As an additional tip, if one module needs to depend on another, you can restrict access to the dependent module using the internal modifier, making it visible only within its own module and not to others. Now, let’s go ahead and create a core module and a feature module. Following that, we’ll discuss setting up the build scripts for these modules.

Finally, Creating Modules

Let’s create model module inside the core module.

Module creation from IDE

When setting up a new module, it’s best to specify the package name using the full path shown above. While you can choose a different format, providing the full path enhances clarity and helps maintain a well-organized structure. Once the module is created, we can proceed to update the pre-initialized build file as follows:

build.gradle.kts at :core:model

plugins {  
alias(libs.plugins.starter.android.library)
}

android {
namespace = "com.example.starter.core.model"
}

dependencies {
// Add relevant dependencies here
}

Just like that, we’ve applied our convention plugin, and the module is now ready for use. Now, let’s look at the build script for a feature module.

build.gradle.kts at :feature:home

plugins {
alias(libs.plugins.starter.android.library.compose)
alias(libs.plugins.starter.android.feature)
}

android {
namespace = "com.example.starter.feature.home"

}

dependencies {

}

We added library compose and feature convention plugins to the module we defined. We completed our setup. Additionally, I’ve implemented type-safe navigation between feature modules to enhance integration.

Project Structure

Our project structure is now complete. You’re all set to develop your multi-module application. As you progress, feel free to add new modules and features. You can customize your existing convention plugins or create new ones to handle common build logic more efficiently. Additionally, diving into a multi-module structure will naturally deepen your understanding of Gradle. You might face some challenges if you’re setting up for the first time or are relatively new to this. In the final section, I’ll offer tips and solutions to help you navigate these hurdles.

Keep in mind

When creating multi-module project, it’s crucial to sync it frequently, especially after minor changes. This practice helps you identify and fix issues more efficiently. Here are some tips to ensure a smooth project sync:

Version Catalog: Start by setting up your version catalog thoughtfully. Decide on a consistent naming convention for your dependencies. Also, you can use “-” or “.” when writing convention plugins. Gradle can interpret both. However, choosing one style and sticking with it for consistency is essential.

Sync Errors: If you encounter a sync error because Gradle cannot resolve a dependency or plugin, try commenting out the problematic script and syncing again. This approach might allow Gradle to resolve other dependencies correctly. Once the sync is successful, you can uncomment the script and attempt another sync.

Type-Safe Project Accessors: We use using it in our scripts for type-safe access. If you change a module’s name, the app should still run correctly, but you might notice a red warning in the IDE. To resolve this, manually delete the relevant folder inside the .gradle directory at the project level and rebuild the project. This action clears the cache and eliminates the error.

Android Gradle Plugin (AGP) Version: I am using AGP version “8.3.1”, supported by Android Studio Iguana and newer versions. For solo projects, consider using the latest IDEs for enhanced performance benefits, such as faster build times, access to newer AGP versions, and early access to new features and improvements.

AndroidManifest.xml: When you create a new module in your Android project, Gradle automatically generates an AndroidManifest.xml file for that module. This file is where you can specify any module-specific logic, such as activity declarations, service registrations, and permissions unique to the module.

During the build process, Android merges all individual module manifest files into a single, consolidated AndroidManifest.xml file for the application. This merging process ensures that all necessary configurations are included in the final app.

Consider centralizing most of your manifest logic in the app module’s manifest file to keep your project organized and simplify the management of permissions and other manifest entries. Doing so creates a single point of reference for the application’s requirements and configurations, making it easier to manage and review the permissions and services your application needs. This approach can reduce redundancy and potential conflicts between module manifests.

Proguard Files: Gradle automatically generates .pro files for new modules. If you don’t have specific rules to apply, feel free to delete these files and add them later as needed.

Setting Up the Project via CLI

Multi Module Builder YT Video

Just download the zip file from the repository and in the terminal specify the name and package name. Open the created folder with Android Studio, and after a couple of minutes (it’s seconds if your PC is fast), the project is ready. For further instructions, check the README file.

Android Multi Module: Starter repository

For further reference, you can check out these repositories:

If you find these repositories helpful, consider giving them a star!

Congratulations on making it this far; hooray for you! This article has turned out to be longer than I initially anticipated, but I hope you find it useful.

If you’re uncertain about what features to add or how to develop your project further, I recommend exploring other repositories on GitHub and reading the official documentation for guidance. To enhance your learning, remember that some skills are best learned through hands-on experience. Practice, fail, and fix are all you need.

For anything, feel free to contact me on LinkedIn.

Take care!

--

--