Gradle Build Files: Consistency Over Cute, Every Time
The danger of general-purpose languages for build systems
In the Java ecosystem, two build tools dominate the landscape: Maven and Gradle. Whereas Maven is XML oriented and therefore trades human readability for consistency, Gradle's DSL approach allows for more concise and human-readable build definitions. While there are certainly benefits to human readability, it also involves a risk which can, quite insidiously, invade your organization.
As Gradle build scripts are pieces of Groovy or Kotlinscript, you can get quite cute in your build files, because you can do anything. As a developer, this might sound like a fantastic thing because you don't have any constraints, but as Spiderman's Uncle Ben once said:
"With great power comes great responsibility."
And unfortunately, this is where things take a turn for the worse. Mind you, this post is not to diminish Gradle's accomplishments within the Java community about building software. It's instead a warning about what can happen if you use general-purpose languages for things that should have a well-defined, narrow scope, and how people can get carried away when presented with all the power such a language provides.
Let me take a piece of Gradle code I wrote years ago, where I thought I found a cool way to manage dependencies:
The relevant pieces of code here are:
...
def props = new Properties()
file("dependencies.properties").withInputStream { props.load(it) }
ext.dependencyVersions = new ConfigSlurper().parse(props)
...
dependencies {
testCompile "junit:junit:${dependencyVersions.junit}"
testCompile "org.codehaus.groovy:groovy-all:${dependencyVersions.groovy}"
...
}
...
Neat, right? I can manage the versions of my dependencies in a single file. At that time, I believed that it was a great application of the Single Responsibility Principle, where the versions could be managed independently from the actual dependency declarations. I was wrong. Getting cute with your Gradle files is not a good idea. It severely violates another vital principle, the one of least surprise. If I open up a Maven POM, I immediately know where my dependencies are defined. They might use properties in the dependencies, but you set those properties in a well-known and standardized location. There are not many things that can surprise me when I'm looking at a Maven POM.
But why would one care about this? After all, as long as your application builds when you run gradle build
, everything's okay. From an individual developer aspect, you may be right. But once you're looking at organizations with hundreds of repositories building their software with Gradle, you probably don't want every single repository to be its unique little snowflake with different, equally technologically cute techniques inside of them.
There are a couple of well-known open-source organizations on Github you can have a look at to see what I'm talking about here. They all use Gradle extensively, and in each organization, there are a couple of interesting approaches. Let's take, for example, one of Square's repositories, kotlinpoet. They're using the Gradle Kotlinscript variant in some projects, so that's already one variant in the organization. In that particular repository dependencies are defined like this:
api(deps.kotlin.stdlib)
Now comes the fun part, trying to figure out where the dependencies are defined. Here's a hint: it's not in a Gradle build file. It's in a Kotlin file in buildSrc
which defines a deps
object. Cute.
Consistency and simplicity matters
Having to go to a not-so-well-known location in Gradle projects (you should not use the buildSrc
folder for this) to find out where a specific version is defined is a bad idea, no matter how clever it is. You need a certain level of consistency in your organization to keep things efficient and maintainable. Being cute in your Gradle build files does not achieve that goal and causes a degree of cognitive dissonance that reduces the productivity of your teams.
Consistency, in the end, enables you to build reasonably simple automations because you know how something looks like, and you'll know how tools can manage your code. You don't need to apply smart techniques to have a machine or process to figure out what the hell you're doing. Inconsistency can even make this an impossible task, which leaves you in the situation where you're doomed to keep maintaining it manually and using up your precious time.
What does this mean for your Gradle build files?
Well, you keep the rules as simple as possible. You do not get cute with your build files and adhere to the best practices or agree on a method within your organization (hopefully standing on the shoulders of giants). Your build files might look more tedious, but tediousness is good in this case. Keep it simple so you can make smart automations. A possible standard might be:
dependencies {
...
implementation "org.springframework:spring-core:5.0.0.RELEASE"
testImplementation "junit:junit:4.7"
testImplementation("org.codehaus.groovy:groovy-all:2.0") {
exclude ...
}
}
So basically having dependencies adhere to the format <configuration> "<group>:<artifact:<version>"
. Easy to parse, easy to extract, and eventually easy to automate. And if you don't think that applying a single best practices on build file structure in your organization is possible, there's always Maven, which enforces certain structures and standards through its XML schema.
But why is this important?
Automation tools can leverage consistency to be able to manage and alter your Gradle build files automatically. But if you start using cute approaches to define your dependencies, you're making it utterly impossible for automation tools to parse and edit your build files.
If you want to be able to take advantage of automation tooling or make your builds less of a unique snowflake in every project, my tip is to make your Gradle build files consistent.