Gradle Dependency Management. From Chaos to Order: The Key to Efficient Project Development

Roman Glushach
11 min readJul 6, 2023

--

Gradle Dependency Management

Dependency management is a crucial aspect of software development. It ensures that the project has access to the required libraries and frameworks, and that these dependencies are up-to-date and compatible with each other. Gradle is a powerful build automation tool that simplifies dependency management and makes it easier to manage complex projects.

Gradle dependency management allows you to specify the dependencies of your project in a declarative and concise way, using a DSL (domain-specific language) called Gradle Kotlin DSL or Groovy DSL. You can also use Gradle plugins to add support for specific frameworks or platforms, such as Spring Boot, Hibernate, and others.

Benefits of Using Dependency Management

  • avoid conflicts and compatibility issues between different versions of the same library or module
  • keep your project up-to-date with the latest bug fixes and security patches from your dependencies
  • reduce the size of your build output by excluding unnecessary or transitive dependencies
  • improve the performance and reliability of your build process by caching and reusing resolved dependencies

Gradle dependency configuration basics

Gradle manages dependencies using a declarative approach. You specify your dependencies in a build.gradle (Goovy) or build.gradle.kts(Kotlin) file using a DSL.

buildScript

buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.springframework.boot:spring-boot-gradle-plugin:latest'
}
}

apply plugin: 'org.springframework.boot'

The Gradle buildScript is executed in a specific order. When you run a Gradle build, Gradle first reads the buildScript and creates a project object. The project object represents your project and contains information about the project, such as its name, version, and dependencies. Gradle then executes the build script, which defines the tasks that Gradle will perform. Each task is defined using a task object, which contains information about the task, such as its name, description, and dependencies.

That’s why the buildScript should be on the first line of you file. Otherwise you’ll get an error.

Gradle build script is divided into three sections:

  • Initialization: defines the basic settings for your project. This includes defining the project name, version, and dependencies. You can also define any custom plugins or repositories that your project needs
  • Configuration: defines the tasks that Gradle will perform. This includes defining the dependencies between tasks and specifying the actions that each task will perform. You can also define custom tasks and plugins in this section
  • Execution: defines the tasks that Gradle will execute when you run the build. This includes specifying the order in which tasks should be executed and any command-line arguments that should be passed to the build

Can contain any of the following elements:

  • Plugins: These are extensions that add functionality to Gradle, such as support for different languages, frameworks, testing tools
  • Repositories: These are locations where Gradle can find the dependencies for your project, such as Maven Central, JCenter, or your own custom repository
  • Dependencies: These are the external libraries or modules that your project depends on, such as JUnit, Spring Boot, or another Gradle project
  • Tasks: These are the actions that Gradle performs during the build, such as compiling, testing, packaging, deploying
  • Configurations: These are named sets of dependencies that can be used for different purposes, such as compile time, runtime, test time
  • Properties: These are variables that can be used to store and access values in your build script, such as project name, version, source directories
  • Methods: These are reusable blocks of code that can be invoked from other parts of your build script, such as custom logic, helper functions

Limitations

  • can only be used in the main build script or thesettings.gradlefile
  • cannot be used in script plugins or init scripts
  • is not idempotent or side-effect free, which means that it can produce different results each time it is executed
  • can be slow to execute, especially if it contains a large number of dependencies
  • can be difficult to maintain, especially if it contains a large number of dependencies or if the dependencies change frequently

plugins

plugins {
id 'java'
}

The plugins block is a new method of applying plugins in Gradle. It was introduced in Gradle 2.1 and is now the recommended way of applying plugins. The plugins block is a closure that contains a list of plugins to apply to the current project.

Advatanges

  • It allows you to use the plugin ID instead of the full class name, which is shorter and easier to remember
  • It automatically resolves the plugin dependencies from the Gradle Plugin Portal or other repositories, so you don’t have to specify them manually
  • It applies the plugins in a predictable and consistent order, which avoids potential conflicts and errors

Limitations

  • It can only be used in the top-level build script of your project, not in subprojects or script plugins
  • It can only apply plugins that have been published with the Gradle Plugin Development Plugin, which means some third-party plugins may not be compatible with it
  • It can only apply one version of a plugin per project, which means you cannot use different versions of the same plugin for different subprojects

repositories

repositories {
mavenCentral() // maven repository
mavenLocal() // local maven repository
maven { // external repository
url "http://repo2.mycompany.com/maven2" // Look for POMs and artifacts, such as JARs, here
artifactUrls "http://repo.mycompany.com/jars" // Look for artifacts here if not found at the above location
}
flatDir { // flat directory repository
dirs 'lib'
}
}

The repositories block allows you to define one or more repositories for Gradle to search for dependencies. A repository is a source of artifacts, such as JAR files, that Gradle can download and use in your project. Gradle supports different types of repositories, such as Maven, Ivy, or flat directories. You can also define custom repositories using plugins or scripts.

The repositories block is usually placed at the top level of your build.gradle file, but you can also define it inside subprojects or configurations. The order of the repositories matters, as Gradle will search them in the order they are declared and stop at the first one that contains the requested artifact. You can use the name property to give a repository a descriptive name for logging purposes.

Limitations

  • Strict limitation to declared repositories: Gradle has a strict limitation to declared repositories. Maven POM metadata can reference additional repositories, but Gradle will only use the declared repositories
  • Cannot be used in script plugins or init scripts: The repositories block can currently only be used in a project’s build script and the settings.gradle file. It cannot be used in script plugins or init scripts. Future versions of Gradle will remove this restriction
  • Limited filtering options: While it is possible to filter repositories by explicit group, module, or version, either strictly or using regular expressions, there are limited filtering options by resolution context, such as configuration name or configuration attributes
  • Cannot start with org.gradle: Precompiled script plugin names cannot start with org.gradle and cannot have the same name as a built-in plugin
  • Default plugin block can only resolve core plugins: By default, the plugin block can only resolve core plugins provided by Gradle or plugins already available to the build script. External plugins with the same version can be applied to subprojects using the buildscript block
  • Dependency cache bypass: When a repository is configured, Gradle totally bypasses its dependency cache for it as there can be no guarantee that content may not change between builds
  • Limited to build script configuration: The repositories block is limited to configuring repositories and dependencies used by all modules in the project, such as libraries that are used to create the application. Module-specific dependencies should be configured in each module-level build.gradle file

application

application {
mainClass = 'demo.App' // Define the main class for the application
}

The application block is used to define the main class of the application, the classpath, and other application-specific settings.

Limitations

  • only available when the application plugin is applied to the project
  • is not available in all Gradle projects. For example, it is not available in projects that use the java-library plugin
  • is not available in multi-project builds that use the java plugin. In this case, the application plugin must be applied to each subproject that needs it
  • is not available in settings.gradlefiles

tasks

tasks.named('test') {
useJUnitPlatform() // Use JUnit Platform for unit tests
}

tasks.register('hello') { // register new task
doLast {
println 'Hello world!'
}
} // to run the task: `gradle -q hello`

The tasks block is used to define tasks that can be executed as part of the build process.
There are two types of tasks in Gradle:

  • Simple tasks: are defined with an action closure that determines the behavior of the task
  • Enhanced tasks: have built-in behavior and provide properties that can be used to configure the task

Most Gradle plugins use enhanced tasks. It is often a good approach to package custom task types in a custom Gradle plugin, which can provide useful defaults and conventions for the task type, and provides a convenient way to use the task type from a build script or another plugin.

Limitations

  • Tasks can be slow to execute, especially if they have many dependencies or perform complex operations. To improve performance, Gradle provides tooling to visualize and analyze a project’s dependency graph, and supports incremental builds that skip running tasks that have previously executed with the same inputs
  • Tasks can be difficult to debug, especially in projects with many declared dependencies. Gradle provides tooling to visualize and analyze a project’s dependency graph, and supports build scans that provide detailed information about the build process
  • Tasks can interfere with each other if they share dependencies or resources. To prevent this, Gradle supports parallel execution of tasks using the — parallel flag, and provides a configuration cache that prevents tasks from interfering with each other’s configuration
  • Tasks can be complex to configure, especially if they have many properties or dependencies. Gradle provides a flexible DSL that allows tasks to be configured in a variety of ways, and supports plugins that provide pre-configured tasks for common use cases

configurations

configurations {
smokeTest.extendsFrom testImplementation // extends from the testImplementation configuration to reuse the existing test framework dependency
compileClasspath.extendsFrom(someConfiguration) // declare a configuration that is going to resolve the compile classpath of the application
runtimeClasspath.extendsFrom(someConfiguration) // declare a configuration that is going to resolve the runtime classpath of the application
lockAllConfigurations() // will lock all project configurations, but not the buildscript ones
}

The configurations block is used to declare and configure the various configurations that a project uses. A configuration is a named set of dependencies that can be resolved and used by a project. THe block can be used to customize the behavior of the dependency resolution engine, declare custom configurations, and specify the dependencies for each configuration.

Limitations

  • can become complex and difficult to manage for large projects with many dependencies
  • can be error-prone, especially when declaring custom configurations or modifying existing ones
  • can be verbose and repetitive, especially when specifying the same dependencies for multiple configurations
  • can be difficult to understand and use for developers who are new to Gradle
  • can be limited in its ability to handle complex dependency management scenarios, such as transitive dependency conflicts

dependencyLocking

dependencyLocking {
lockAllConfigurations() // will lock all project configurations, but not the buildscript ones
}

Is a feature in Gradle that allows you to lock the versions of your dependencies to ensure that the same versions are used across all builds. This feature is useful when you have a large project with many dependencies and you want to ensure that all developers are using the same versions of those dependencies.

Limitations

  • it only makes sense with dynamic versions. It will have no impact on changing versions (like -SNAPSHOT) whose coordinates remain the same
  • it is not supported for Android projects
  • ignoring specific dependencies from the lock is not possible
  • there are some issues with dependencyLocking when used with the Kotlin DSL plugin

In addition to dependencyLocking, Gradle also provides the concept of dependency constraints, which allow you to define the version or the version range of both dependencies declared in the build script and transitive dependencies. Dependency constraints are only published when using Gradle Module Metadata. This means that currently they are only fully supported if Gradle is used for publishing and consuming.

dependencies

dependencies {
constraints {
implementation('com.google.guava:guava:31.1-jre') {
version {
strictly '31.1-jre'
}
}
}

implementation('com.google.guava:guava:31.1-jre')
api project(':list')
api 'com.fasterxml.jackson.core:jackson-databind:latest'
implementation 'org.springframework.boot:spring-boot-starter:latest'
implementation gradleApi()
testImplementation 'junit:junit:latest'
testRuntimeOnly 'org.postgresql:postgresql:latest'
runtimeOnly 'com.h2database:h2:latest'
runtimeOnly fileTree('libs') { include '*.jar' }
}

The dependencies block is used to declare dependencies for a project. It allows you to specify the dependencies that your project requires to compile, run, or test. The dependencies block can be used to declare dependencies on external libraries, other projects, or modules within the same project. It can also be used to exclude transitive dependencies, downgrade dependency versions, and enforce dependency constraints.

Configurations

  • compileOnly: dependency is not required at runtime, but is required for compilation. This is useful for dependencies that are only needed during the build process
  • compile: is now deprecated and should not be used in new projects. It was previously used to specify dependencies that are required for compilation and runtime
  • implementation: dependency is required for compilation and runtime. This is the most commonly used configuration for dependencies
  • runtimeOnly: dependency is not required for compilation, but is required at runtime. This is useful for dependencies that are only needed during the execution of the application
  • runtime: dependency is not required for compilation, but is required at runtime. This is useful for dependencies that are only needed during the execution of the application
  • testCompileOnly: dependency is not required at runtime, but is required for compiling tests. This is useful for dependencies that are only needed during the test phase
  • testImplementation: dependency is required for compiling and running tests
  • testRuntimeOnly: dependency is not required for compiling tests, but is required at runtime for running tests
  • testRuntime: dependency is not required for compiling tests, but is required at runtime for running tests
  • api: behaves just like compile (which is now deprecated), and you should typically use this only in library modules. When a module includes an api dependency, it's letting Gradle know that the module wants to transitively export that dependency to other modules, so that it’s available to them at both runtime and compile time

Limitations

  • can become difficult to manage when there are many dependencies or when there are complex dependency relationships. This can lead to issues with transitive dependencies, where a dependency required by one of your dependencies conflicts with another dependency required by your project. To avoid this, Gradle provides the concept of dependency constraints, which allow you to define the version or the version range of both dependencies declared in the build script and transitive dependencies
  • only allows you to declare dependencies for a single configuration at a time. This can be problematic when you need to declare dependencies for multiple configurations, such as when you have different dependencies for compile time and runtime. To address this, Gradle provides the concept of dependency configurations, which allow you to declare dependencies for multiple configurations at once

publishing

publishing {
publications {
library(MavenPublication) {
from components.java
}
}
repositories {
maven {
url = uri("${buildDir}/publishing-repository")
}
}
}

The publishing block is used to configure the publication of artifacts to a repository. The publishing block can be used to define publications, repositories, and other settings related to publishing.

Limitations

  • Certain repositories may not be able to handle all supported characters. For example, Maven restricts groupId and artifactId to a limited character set
  • cannot be defined inside other blocks

Conclusion

Gradle is a powerful tool for managing dependencies in software projects. It simplifies the process of building, testing, and deploying complex software projects. It allows developers to define dependencies, plugins, and tasks in a declarative way, using a Groovy-based domain-specific language (DSL). Grable also supports incremental builds, parallel execution, and caching, which can significantly improve the performance and reliability of software development.

--

--

Roman Glushach

Senior Software Architect & Engineer Manager at Freelance