The Backstory
When we were bootstrapping things at StackHawk, we made the decisions to:
Not use a mono-repo
Avoid dependencies across repos based on filesystem layout assumptions
That did require us to handle shared dependencies right up front, but the payoff is that things are scaling nicely as services and team members are added. For our SpringBoot/Kotlin/Gradle projects, this meant each one had their own plugins
and dependencies
blocks that look like this:
plugins {
id("org.springframework.boot") version "2.2.1.RELEASE"
id("io.spring.dependency-management") version "1.0.8.RELEASE
[...]
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-mustache")
[...]
}
The Drifting Problem
Fast-forward a bit and now that we have more than a few services in their own repos, each with their own plugins
and dependencies
blocks, we have a drift problem. We recently wanted all projects to be on a specific version of the SpringBoot plugin, and we realized that updating every project on its own is tedious and error-prone. Additionally, we noticed that despite our best efforts, the Gradle configs across our relatively young projects had drifted – versions were out of sync, some projects were missing plugins, etc.
This kind of drift can cause subtle issues around expected behavior between projects; if someone in Project A builds functionality around a library function, and then they move to Project B that’s using a different version with different behavior. Or if someone is using a development tool in Project X that automatically safeguards against a condition, and then they move to Project Y that does not have that tool wired in and they erroneously assume they don’t need to deal with the condition.
Back On Track
Gradle generally wants multi-project builds to be in the same repo, using filesystem-relative things like include
, project
, or includeFlat
to pull common configs together. But since we don’t have a mono-repo and we do not want to assume multi-project filesystem layouts, we didn’t want to use those Gradle features.
Naturally, Gradle does support sharing plugins and dependencies via published artifacts using the Maven coordinate convention, but it requires a little bit of work. Turns out, a custom plugin is needed for plugins, and a custom platform is needed for dependencies. Both of these were easy to build and incorporate into our codebase.
Custom Plugin
Plugins can be shared across projects by writing a custom plugin in its own project.
Set up a custom plugin project using the
maven-plugin
andjava-gradle-plugin
plugins in thebuild.gradle.kts
fileWrite a custom plugin that extends
Plugin<Project>
In the
apply
method of the custom plugin, use theproject.plugins
object toapply
plugins you want shared via their respective plugin idSpecify the dependent plugin libraries via their classpath Maven coordinates that are referenced by their plugin id in the custom plugin’s
build.gradle.kts
file.Note that the plugin ID and the classpath coordinates are different things and that https://plugins.gradle.org/ will give you both pieces of info.
Use the
maven-publishing
plugin to publish the custom plugin to a Maven repoIn the dependent project, pull in the custom plugin by its plugin id in the
plugins
block of thebuild.gradle.kts
In order for the dependent project to find this new plugin via it’s id in the Maven repo, you’ll need to add your repo to the plugin configuration. We did this in the dependent project’s
settings.gradle.kts
, in thepluginManagement
block.
Example custom plugin build.gradle.kts
plugins {
`maven-publish`
`java-gradle-plugin`
kotlin("jvm") version "1.3.70"
}
group = "com.stackhawk.plugins.example"
version = "0.0.1"
java.sourceCompatibility = JavaVersion.VERSION_1_8
repositories {
// Need to include plugin repo
gradlePluginPortal()
mavenCentral()
mavenLocal()
jcenter()
}
dependencies {
// Plugin classpath artifacts with specific versions
implementation("org.springframework.boot:spring-boot-gradle-plugin:2.2.5.RELEASE")
implementation("io.spring.gradle:dependency-management-plugin:1.0.9.RELEASE")
val kotlinPluginVersion = "1.3.70"
implementation(kotlin("gradle-plugin:${kotlinPluginVersion}"))
implementation(kotlin("allopen:${kotlinPluginVersion}"))
implementation(kotlin("noarg:${kotlinPluginVersion}"))
}
publishing {
repositories {
maven {
url = uri("http://private-repo/")
}
}
}
gradlePlugin {
plugins {
create("example") {
// This ID is used by the dependent projects in the plugins block
id = "com.stackhawk.example"
implementationClass = "com.stackhawk.gradle.ExamplePlugin"
}
}
}
Example custom plugin class
package com.stackhawk.gradle
import org.gradle.api.Plugin
import org.gradle.api.Project
class ExamplePlugin : Plugin<Project> {
override fun apply(project: Project) {
// Shared Plugin IDs
// Also add the Plugin's classpath artifact in build.gradle.kts
project.plugins.apply("idea")
project.plugins.apply("jacoco")
project.plugins.apply("org.springframework.boot")
project.plugins.apply("org.springframework.boot")
project.plugins.apply("io.spring.dependency-management")
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.plugins.apply("org.jetbrains.kotlin.plugin.spring")
project.plugins.apply("org.jetbrains.kotlin.plugin.jpa")
project.plugins.apply("org.jetbrains.kotlin.plugin.noarg")
}
}
Example dependent project build.gradle.kts
plugins block
plugins {
id("com.stackhawk.gradle") version "0.0.4"
}
Example dependent project settings.gradle.kts
rootProject.name = "yarak"
pluginManagement {
// Needed to find our new custom plugin
repositories {
gradlePluginPortal()
mavenLocal()
maven {
url = uri("http://private-repo/")
}
}
}
Custom Platform
Managing dependencies across projects is done via a platform, which can either be a local project or a Maven BOM (AKA a Parent POM). Luckily, Gradle provides nice tooling that will auto-generate a BOM for your project via the java-platform
plugin.
This requires a separate platform project, as platform projects are disallowed to have actual source code in them:
Set up a platform project using the using the
maven-plugin
andjava-platform
plugins in thebuild.gradle.kts
fileIn the
dependencies
block, use aconstraints
block to specify dependencies and versions using theapi()
call for desired Maven coordinates.Use the
maven-publishing
plugin to publish the custom plugin to a Maven repoIn the dependent project, specify the new
platform
in the dependencies block using theapi()
call with Maven coordinates
Example platform build.gradle.kts
plugins {
`java-platform`
`maven-publish`
}
group = "com.stackhawk"
version = "0.0.1"
dependencies {
constraints {
// Platform declares specific versions of libraries used in subprojects
api("software.amazon.awssdk:aws-sdk-java:2.10.90")
api("com.ninja-squad:springmockk:2.0.0")
api("org.apache.httpcomponents:fluent-hc:4.5.12")
}
}
publishing {
repositories {
maven {
url = uri("http://private-repo")
}
}
}
Example dependent project build.gradle.kts
dependencies block:
dependencies {
// Use our custom platform via the Maven POM
api(platform("com.stackhawk:aerie:0.0.1"))
// Dependencies don't need specific versions, the platform fills those in
implementation("software.amazon.awssdk:aws-sdk-java")
testImplementation("com.ninja-squad:springmockk")
testImplementation("org.apache.httpcomponents:fluent-hc")
}
Summary
This solution fits in nicely with our filesystem-layout-agnostic/multi-repo setup and makes our service’s build files more consistent and easier to manage. Downstream projects simply include the custom plugin and platform and automatically are in line with our dependency versions. Adding the plugin and platform to our build system has added a little overhead, but the trade-offs make it worthwhile.