Gradle, multi-project builds explained
In this article, I will aim to explore and explain some of the most frequently used Gradle features. My focus will primarily be on multi-project builds, as this is the prevalent type of Gradle build you encounter. To facilitate comprehension, I’ll provide substantial code examples to illustrate the concepts and solutions. Without further ado, let’s dive into the critical aspects.
Gradle is a versatile, robust, and powerful build tool that has been widely adopted by companies worldwide. It leverages a declarative syntax to define both simple tasks and complex build processes, making it suitable for large, intricate, multi-project applications. Gradle is renowned for its readable DSL language, flexible structure, and effective dependency management. Another noteworthy aspect is Gradle’s compatibility with Kotlin in addition to Groovy, which becomes increasingly relevant with Kotlin’s growing popularity. You can explore the complete list of features on the official Gradle website at https://gradle.org/features.
Gradle’s popularity is such that virtually every Java programmer, and many others, will encounter Gradle build files at some point in their careers. Being prepared for this is essential, as a well-implemented build process can save time and reduce stress for every member of a project team. Additionally, preparedness is crucial due to the potential pitfalls that can arise in any building process.
Why should you bother?
Firstly, I’d like to provide a bit of clarification. Throughout this article, I will refrain from using the term “multi-module build” as a synonym for “multi-project build.” While you may encounter both terms in practice, it’s important to note that “multi-project build” is the technically correct term. Gradle is unambiguous in this regard and employs “multi-project build” consistently in its documentation. Clarity is paramount, especially now with the advent of Java 9 modules. Overloading terms can lead to confusion when precision is required.
Now, why are multi-project builds significant? Why can’t we simply build separate applications and deliver them independently to the client? The answer lies in dependency management. Whenever several of your projects rely on each other, you’ll need to build, package, and release them together in a cohesive manner. In my view, this is the most compelling reason to adopt a multi-project build process.
You might wonder, “Why not just merge these projects into one?” This is a valid question, and there are numerous arguments against consolidating everything into a single large project, particularly in the era of microservices. By keeping projects separate, you can maintain logical and domain separation, which is advantageous during software development. The entire team doesn’t have to be familiar with all projects, and the chances of team members working on the same section of code are reduced. This approach enhances team productivity and facilitates parallel development. Additionally, segregating logic into distinct projects enhances code readability and helps prevent code duplication.
With this context in mind, I assume you are interested in exploring what Gradle has to offer in this context.
Basics
In the most common scenario, the Gradle project has two types of interesting files. The first one is commonly placed in the root project directory and called settings.gradle.kts (keep in mind that location is not a requirement, and neither is the name). In a single project build, it will most probably consist of just a project name. It’s a place for you to declare all project-scoped settings and include your projects to build.
rootProject.name = "donut_factory"
include("hole_maker", "sugar_producer", "happiness_provider")
settings.gradle.kts
The second type of file is the so-called build file. There should be one file for each project, placed in respective directories, and one root file placed in a root directory. The root build file is usually used to share some of the most common configurations, while each particular build file will handle the build process of its respective project.
plugins {
java
id("org.springframework.boot")
}
repositories {
mavenCentral()
jcenter()
}
dependencies {
annotationProcessor(group = "org.projectlombok", name = "lombok", version = lombokVersion)
implementation(group = "org.springframework.boot", name = "spring-boot-starter")
implementation(group = "org.springframework.boot", name = "spring-boot-starter-web")
implementation(group = "org.springframework.boot", name = "spring-boot-starter-data-jpa")
implementation(group = "org.springframework.boot", name = "spring-boot-starter-security")
implementation(group = "org.postgresql", name = "postgresql", version = postgreSqlVersion)
implementation(group = "javax.inject", name = "javax.inject", version = javaxInjectVersion)
implementation(project(":hole_maker"))
implementation(project(":sugar_producer"))
implementation(project(":happiness_provider"))
compileOnly(group = "org.projectlombok", name = "lombok", version = lombokVersion)
testImplementation(group = "org.junit.jupiter", name = "junit-jupiter-api", version = junitVersion)
testImplementation(group = "org.assertj", name = "assertj-core", version = assertjVersion)
testImplementation(group = "org.springframework.boot", name = "spring-boot-starter-test")
testImplementation(group = "org.springframework.security", name = "spring-security-test")
testImplementation(group = "org.mockito", name = "mockito-junit-jupiter", version = mockitoVersion)
testAnnotationProcessor(group = "org.projectlombok", name = "lombok", version = lombokVersion)
testRuntimeOnly(group = "org.junit.jupiter", name = "junit-jupiter-engine", version = junitVersion)
testCompileOnly(group = "org.projectlombok", name = "lombok", version = lombokVersion)
}
root build.gradle.kts
As you can see there is no magic there. I will focus on the multi-project part ignoring the rest. Besides declaring projects in settings.gradle.kts file you will also need to tell Gradle which project depends on which. It’s no different than declaring external dependency, except the project() part which is responsible for creating a dependency on your project without adding it to a configuration (this was already done earlier). That’s pretty much all about basics. You can now build your projects together.
Manage your dependencies with grace
Dependency management is crucial when speaking about multi-project builds. You can easily end up with an unmaintainable build file with a lot of dependencies. This is not only a clear code issue but also an opportunity to speed up your build, as fetching unnecessary dependencies can slow it down a lot. Fortunately Gradle, again, has some very handy solutions to offer and it will, again, be our known friend – buildSrc directory. Having the possibility to treat it as normal code allows us to shape dependencies in our own, often very elaborated, way. There are two most common ways to improve the build script: transforming dependencies into constant fields of an object and preparing a different object for different types of dependencies. Having properly named and grouped dependencies is a half-success. Moreover, you will have the help of your IDE, which will greatly improve your experience.
object Versions {
val junit = "2.3"
val pumpkin = "5.6"
}
object TestingLibraries {
val junit = "org.junit.jupiter${Versions.junit}"
}
object FoodLibraries {
val pumpkin = "de.pumpkin.world${Versions.pumpkin}"
}
dependencies.kts
implementation FoodLibraries.pumpkin
testImplementation TestingLibraries.junit
build.gradle.kts
Conclusion
It’s always good to know your tool, especially when it accounts for limiting your productive time. You don’t always need to be a Gradle expert, but knowing basic concepts and a few tricks can greatly improve your and your team’s overall experience. Being wise in terms of the build process and treating build scripts as first-class citizens is an investment with a high return.
If you’d like to see these concepts applied in a real project setting, take a look at a practical Kotlin library project using Gradle that we build internally.