Demystifying Dependency Management in Android Development

Daniely Murua
wearejaya
Published in
12 min readJun 23, 2023

Have you ever found yourself in a situation where you had to set up a project from scratch and felt lost when it came to organizing dependencies? And what about having a project with multiple modules and needing to define the necessary dependencies for each one of them? The confusion just keeps growing… Not to mention the difficulty of keeping up with and updating all those libraries. The way we organize and structure dependencies in our project can be essential to make development more agile, efficient, and easily maintainable.

There are several approaches available to organize and structure dependency configurations. In this article, we will explore different forms of management, ranging from the standard hardcoded implementation to more flexible and scalable approaches.

Default Implementation

Although widely discouraged, the default implementation, where dependencies are directly inserted into the build.gradle, can have its advantages in certain cases. In small projects or when dependencies are stable and do not require frequent updates, this approach can simplify the configuration process and avoid the need for additional dependency management tools.

dependencies {
implementation("com.example:library:1.0.0")
implementation("com.google.android.material:material:1.4.0")
implementation("androidx.appcompat:appcompat:1.3.1")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
}

However, in more complex or larger projects, especially those with multiple modules, this approach can bring several disadvantages:

1. Difficulty in management:
If you need to update a dependency, you will have to manually search for all the places where the dependency is used and update each occurrence individually.

2. Code duplication:
If your project has multiple modules that depend on the same library, hardcoding dependencies in each build.gradle file can lead to code duplication, making maintenance more difficult.

3. Limited flexibility:
This approach can be problematic when dealing with different testing scenarios, different development environments, or when experimenting with different versions of libraries.

Modularization

A commonly adopted approach in slightly larger projects is to divide the project into independent modules, with each module being responsible for a specific functionality. In this case, each module can have its own build configuration file.

While modularization is a beneficial and highly recommended approach to organize code and dependencies in Android projects, persisting the use of hardcoded dependencies within the modules can lead to practically the same issues as the default approach, such as configuration duplication, difficulties in updating and managing transitive dependencies. Therefore, modularization alone can improve code organization and structure, but dependencies still require a more specific approach to ensure efficiency.

Build.gradle File Compositions

A common approach to organize dependencies in Gradle is to create additional build.gradle files where you place specific sets of dependencies that are interconnected or belong to a particular context. These separate configuration files provide a more organized way to handle project dependencies.

Instead of defining all the dependencies in the main build.gradle file, you can create separate configuration files for different parts of the project. For example, you can have a specific configuration file for the networking module dependencies and another one for the UI module dependencies. This way, you can group related dependencies in their respective files, making it easier to understand and manage project dependencies.

The main advantage of this approach is clarity. By having separate build.gradle files for different parts of the project, you can easily visualize the specific dependencies of each module. This also enables you to change the dependencies of each module in isolation, without affecting the rest of the project. For example, if you need to update the networking dependencies, you can simply make the change in the corresponding configuration file without worrying about the other modules.

Additionally, this approach facilitates configuration reuse. If you have multiple modules with similar dependencies, you can simply import the same configuration file into each module, avoiding code duplication and simplifying maintenance.

For example, you can have an android-dependencies.gradle.kts file containing the following commonly used project dependencies:

dependencies {
implementation("androidx.core:core-ktx:1.6.0")
implementation("androidx.appcompat:appcompat:1.3.1")
}

In the main Gradle file, you can apply the imported android-dependencies.gradle.kts file using the apply statement. Here's an example of how you can do that:

apply(from = "android-dependencies.gradle.kts")

...

dependencies {
// Module-specific dependencies
}

By using the apply statement with the from parameter, you can import and include the configuration defined in the android-dependencies.gradle.kts file in your main Gradle file.

While the approach of organizing dependencies in separate build.gradle files brings benefits, there are also some disadvantages to consider:

  • Additional Complexity: Having multiple build.gradle files can increase the complexity of the project, especially in larger projects with multiple modules. It is necessary to ensure that the files are correctly configured and that dependencies are managed consistently.
  • Possibility of Inconsistencies: Since build.gradle files are independent, there is a risk of inconsistencies between them. For example, different versions of the same library might be specified in different files, which can lead to conflicts and unexpected behavior.
  • Difficulty in Visualizing Overall Dependencies: When dependencies are spread across multiple files, it can be more challenging to have an overview of all project dependencies. This can make it harder to identify duplicate dependencies or understand dependencies at a broader level.
  • More Complex Maintenance: As the project grows and new modules are added, maintaining the build.gradle files can become more complex. It is necessary to ensure that dependencies are updated and synchronized correctly across all relevant files.
  • Potential Conflicts with Plugins: Depending on the plugins and tools used in the project, there may be cases where the approach of separate build.gradle files conflicts with these tools, requiring additional adjustments or alternative solutions.

It is important to weigh these disadvantages against the needs and complexity of your project. In some cases, the approach of separate build.gradle files can bring more benefits than drawbacks, but careful implementation and proper maintenance are necessary to avoid future issues.

Now that we understand the importance of an efficient structure for managing dependencies using Gradle, let’s explore some additional techniques that are widely used in Android projects and can complement the approaches mentioned earlier. These techniques offer greater flexibility and further enhance the development and maintenance of the project.

In this article, we will discuss buildSrc and Gradle Version Catalog.

BuildSrc

In this approach, the imperative logic of an Android build process is abstracted into a specific directory named buildSrc within the project. Inside this directory, you can create classes and scripts that define custom tasks, dependency configurations, custom plugins, and more. These classes and scripts are automatically compiled and integrated into the Gradle build process.

Think of it as a mini-project dedicated to managing the build logic of your main project. It has its own build.gradle file, just like any other module, where you can specify dependencies, configurations, and custom tasks.

This approach brings several advantages. First, it helps keep the build code organized and separated from other parts of the project. This makes it easier to read, maintain, and reuse specific build logic across different projects.

In addition, Gradle automatically recognizes changes made in the buildSrc folder and triggers recompilation when necessary. This ensures that any modifications or updates to the code within the buildSrc folder are incorporated into the project's build process. It provides a seamless way to keep the build logic up-to-date and synchronized with the rest of the project. However, it is important to note that this approach may introduce some drawbacks, which will be discussed later.

Let’s move on to the practical part.

When you create a folder named buildSrc in the root directory of your project and build the project, Gradle recognizes it as a special module.

The buildSrc folder must follow a specific directory structure for Gradle to recognize it correctly. Within the buildSrc folder, you should have a directory structure similar to a standard Gradle project, with a src directory that contains the source code files.

1- Create the buildSrc folder
In the root directory of your project, create a folder called buildSrc. Inside the buildSrc folder, create the following directory structure:

buildSrc
└── src
└── main
└── kotlin
└── ...

2- Create Dependencies.kt
Create a file, with a name of your preference, inside the kotlin folder. Here, you can organize your files in any way you prefer. In the example below, we created a file named Dependencies.kt inside the dependencies folder.

3- Define your libraries, versions and plugins
Inside the Dependencies.kt file, you can implement classes to encapsulate your dependencies, versions, plugins, and anything else you find necessary, as shown in the example below:

object Versions {
const val kotlin = "1.5.30"
const val androidGradlePlugin = "7.1.0"
const val retrofit = "2.9.0"
const val okhttp = "4.9.1"
const val glide = "4.12.0"
}

object Dependencies {
const val retrofit = "com.squareup.retrofit2:retrofit:${Versions.retrofit}"
const val okhttp = "com.squareup.okhttp3:okhttp:${Versions.okhttp}"
const val glide = "com.github.bumptech.glide:glide:${Versions.glide}"
}

object Plugins {
const val kotlin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}"
const val android = "com.android.tools.build:gradle:${Versions.androidGradlePlugin}"
}

4- Create build.gradle file
The build.gradle(.kts) file inside the buildSrc folder is the Gradle configuration file specific to the buildSrc project. It defines the settings and plugins required for the proper functioning of buildSrc.

This configuration file typically contains definitions such as the Gradle version to be used, plugins to be applied, additional buildSrc project settings, and other specific customizations.

In the case of buildSrc, the build.gradle file is responsible for compiling the Kotlin code present in the src directory and making it available to the main project.

It’s important to note that the build.gradle file inside the buildSrc folder is different from the build.gradle used to configure the main project. Each one has its own configurations and distinct purposes.

Here’s an example of a build.gradle(.kts) file within the buildSrc folder:

plugins {
`kotlin-dsl`
}

repositories {
mavenCentral()
}

dependencies {
...
}

In this example, we are applying the kotlin-dsl plugin to enable the use of the Kotlin DSL in the configuration file. Then, we configure the repository to fetch dependencies. In this example, we are using the mavenCentral() repository as an illustration. However, you can specify any repositories that you need to download the dependencies for your project.

The buildSrc project lets you declare dependencies for custom build logic separately from the main project. You can add external libraries or plugins specific to buildSrc, without impacting the main project’s dependencies.

4. Implement your dependencies

Now, in the build.gradlefile where you want to use your dependencies, you can import them from the Dependencies.kt class like this:

dependencies {
implementation(Dependencies.retrofit)
implementation(Dependencies.okhttp)
implementation(Dependencies.glide)
}

Much cleaner, right? If you need to update a library, you just have to go to the Dependencies.ktfile and make the change in the Versions object, sync the project and automatically all the occurrences will be updated. You can organize these files in any way you want. For example:

buildSrc
├── src
│ ├── main
│ │ └── kotlin
│ │ ├── Dependencies.kt
│ │ ├── Plugins.kt
│ │ └── Versions.kt
│ └── test
│ └── kotlin
│ └── DependenciesTest.kt
└── build.gradle.kts

This is just a basic example, and you can add more configurations and dependencies as needed to meet the requirements of your buildSrc project.

While buildSrc is a powerful approach for managing dependencies in Gradle, there are some disadvantages to consider:

  • Additional Complexity: Implementing and configuring buildSrc can add complexity to the project. Understanding how to structure and organize files within the buildSrc folder, as well as the recommended conventions and best practices, is necessary.
  • Limitations in Reusability: While buildSrc allows for the reuse of configurations and dependency versions across modules, it is limited to the scope of the project. If you have multiple independent projects or projects that share dependencies among themselves, you will need to adopt other approaches such as creating external plugins or using centralized dependency repositories.
  • Build Overhead: buildSrc is compiled along with the rest of the project, which means that any changes to files within that folder will trigger a full project recompilation. In larger projects, this can result in longer compilation times.

Gradle Version Catalogs

Gradle Version Catalogs is a powerful feature introduced in Gradle 7.0 that allows you to centralize and manage dependency versions in your project. It offers several advantages, including:

  • Centralization of Versions: By centralizing versions, you can update dependencies by modifying the catalog, ensuring consistency across the project and avoiding conflicts.
  • Configuration Sharing: Version Catalogs can be shared across projects, promoting consistency in dependency versions.
  • Automation Support: The flexibility of Version Catalogs extends to supporting third-party plugins. These plugins can automatically update the versions of dependencies to the latest available, saving us from manually managing version upgrades.
  • Support for Modular Projects: Specific versions can be defined for modules or shared dependencies, offering granular control over dependencies.
  • Bundle Creation: Version Catalogs allow the creation of bundles, enabling the addition of a set of libraries with just one implementation() line in the Gradle file.
  • Performance Advantage: Gradle Version Catalogs offer a performance advantage over the buildSrc solution. Unlike buildSrc, where incrementing a version number requires a full build clean and rebuild, Version Catalogs eliminate this step, resulting in faster build times and improved development efficiency.

To centralize dependency versions, you have two options: using the settings.gradle file or a .toml format file. In this article, we’ll focus on the latter, specifically using a file named libs.versions.toml, which is the recommended format.

The .toml files provides a human-readable and user-friendly way to manage dependencies. You can organize your dependencies and plugins into four different sections: versions, libraries, plugins and bundles.

[versions]
#define variables for the versions of dependencies and plugins
[libraries]
#define libraries and their respective versions
[plugins]
#define the plugins
[bundles]
#define libraries bundles

An interesting feature of Version Catalogs is the [bundles] section. It enables the creation of dependency packages, which simplifies their management and inclusion in projects. This feature promotes code reuse and maintainability by grouping related dependencies together in a convenient package format.

Let’s move on to the practical part.

To set up Gradle Version Catalogs in your project, ensure that you are using a Gradle version equal to or higher than 7.4 (stable version). Once you have confirmed the Gradle version, follow these steps:

1- Create libs.versions.toml file
Create a file named libs.versions.toml in the gradle directory of your project.

2- Define your dependencies
In the libs.versions.toml file, add the following blocks and define the versions, libraries and plugins.

[versions]
androidGradlePlugin = "7.4.1"
ktx = "1.9.0"

[libraries]
androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" }
my-lib = "com.mycompany:mylib:1.4"
my-other-lib = { module = "com.mycompany:other", version = "1.4" }
my-other-lib2 = { group = "com.mycompany", name = "alternate", version = "1.4" }
mylib-full-format = { group = "com.mycompany", name = "alternate", version = { require = "1.4" } }

[bundles]
my-lib-bundle = ["my-lib", "my-other-lib", "my-other-lib2"]

[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
short-notation = "some.plugin.id:1.4"
long-notation = { id = "some.plugin.id", version = "1.4" }
reference-notation = { id = "some.plugin.id", version.ref = "common" }

You can create aliases with names containing - (recommended), _, or .. The generated catalog will normalize all keys to use . instead. For example, if you use foo-bar as an alias, it will be automatically converted to foo.bar.

3- Implement your dependencies and apply your plugins
Implement your dependency or apply the plugin in the desired module by referencing the TOML file.


plugins {
alias(libs.plugins.android.application)
}

dependencies {
implementation(libs.androidx.ktx)
...
}

It’s important to note that there are limitations to using Gradle version catalogs. While Gradle supports version catalogs, not all external tools, libraries, or frameworks may be able to support or take advantage of this functionality. This can restrict the applicability of version catalogs in certain projects.

Conclusion

Choosing the most suitable approach for dependency management depends on the specific needs of the project, team size, developers’ expertise, and organizational constraints. Each approach has its advantages and disadvantages, and some can be combined to meet the project’s requirements. With a well-defined dependency management strategy, it is possible to keep the Android project organized, facilitate maintenance, and promote development efficiency.

References

https://developer.android.com/build#:~:text=The%20Android%20build%20system%20compiles,deploy%2C%20sign%2C%20and%20distribute.

https://developer.android.com/build#:~:text=The%20Android%20build%20system%20compiles,deploy%2C%20sign%2C%20and%20distribute.

https://proandroiddev.com/using-version-catalog-on-android-projects-82d88d2f79e5

--

--

Daniely Murua
wearejaya

Mobile engineer at Jaya Tech and gaming enthusiast. 📱🎮