Skip to content

Gradle Build Tips

Tim Yates edited this page May 8, 2024 · 7 revisions

Micronaut Platform and the BOM files

The Platform BOM file vs version catalogs

The Micronaut Platform BOM file

Prior to Micronaut 4, the Micronaut Core project was special in the sense that it serves as the base dependency for other modules, but also produces a BOM file which is used for dependency management.

Since Micronnaut 4, this dual usage has been simplified and the BOM file has been moved to the micronaut-platform project.

A BOM file can be used by both Maven and Gradle builds and allows "driving" dependency versions. There are however different behaviors depending on the build tool you're using:

  • In Maven, if the BOM is imported in a build (but not if used as a parent), the versions of libraries declared in the BOM file would "win" over the versions declared in transitive dependencies. Unfortunately while this works well for simple cases, it's common that Maven users face ordering issues, or that other BOMs conflict with what we would provide.
  • In Gradle, a BOM is considered a "platform" and participates in dependency management conflict resolution like any other dependency: versions declared in the BOM files are considered constraints for resolution, and do not prevent upgrades. They would apply even if no direct dependency uses the library.

Micronaut has historically produced a BOM file which helps users managing dependencies. Maven users can "override" dependency versions by changing the value of some properties defined in the BOM. Gradle users cannot do this but can override dependency versions by adding additional strict constraints.

All in all, the generation of the BOM file is automated in the Micronaut build.

  • the platform bom project is the one which generates (and publishes) a BOM file from a model of dependencies we want to include in the BOM. Such dependencies are called managed dependencies.

Version catalogs

Since Gradle 7, Gradle provides an experimental feature called version catalogs. Version catalogs are responsible for standardizing how dependency versions are declared, and shared, within a multi-project build, and can even be shared by different builds. This is a drop-in replacement for many different patterns we find in the wild: dependencies.gradle files, ext.fooVersion, versions declares as static constants in buildSrc, ... Version catalogs also have the advantage of providing type-safe accessors, which is extremely useful for users which use the Kotlin DSL as they would get auto-completion of dependencies in build files.

A version catalog has different semantics than a BOM file (or platform in the Gradle terminology). You can read about the differences in the Gradle documentation but in a nutshell, here's a quick summary of the main difference:

  • if a user applies a BOM file, all entries of the BOM file participate in dependency resolution. It means that if a library appears in the dependency graph via transitive dependency resolution and that it appears in the BOM, then Gradle would perform conflict resolution between the version in the BOM and the transitive version.
  • in opposite, if a user declares a dependency using a library declared in a catalog, only that dependency participates in the resolution. As such, a catalog acts more as a set of recommended versions that you can pick from.

It is, therefore, perfectly legit to use both a BOM and a platform.

Version catalogs usage in Micronaut modules

Declaring dependencies in build files

Micronaut modules now use version catalogs for their own builds to declare dependency versions. This means a number of things:

While it is possible, you should avoid declaring dependency coordinates directly in a build file. Instead of:

dependencies {
    api "jakarta.inject:jakarta.inject-api:$jakartaApiVersion"
}

you will write:

dependencies {
    api libs.jakarta.inject.api
}

The libs.jakarta.inject.api is declared in a file found at gradle/libs.versions.toml. This file has two main sections (that we use in this build): the [versions] section and the [libraries] section. By convention, we would declare the dependency above using one entry in the [versions] section and one in the [libraries] section:

[versions]
# ...
jakarta-inject-api = ""
# ...

[libraries]
# ...
jakarta-inject-api = { module = "jakarta.inject:jakarta.inject-api", version.ref = "jakarta-inject-api" }
# ...

Separating the two allows sharing version numbers between several libraries. Note how the dashed notation is converted to dots in the Gradle build file. The hierarchy which is built with those dashes allow grouping dependencies together in semantic ways and make it easier to discover via code completion.

Managed vs non-managed dependencies

We saw that Micronaut generates a BOM file from dependency metadata. Actually, the source of truth for dependencies which should appear in the BOM file is the version catalog. Therefore, we make the difference between two kinds of dependencies:

  • "managed" dependencies are dependencies which should participate in the BOM file. In the catalog file, such dependencies (and their versions) must have an alias which starts with managed-. For example (taken from the micronaut-cache module):

    [versions]
    # ...
    managed-caffeine = "3.1.8"
    # ...
    [libraries]
    # ...
    managed-caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "managed-caffeine" }
    # ...

    and in the build file:

    implementation libs.managed.caffeine
  • "internal" dependencies are non managed and can use any other prefix.

Gradle will use this information to infer what to put in the BOM.

Therefore, if you need to add a dependency, you should ask yourself whether it should appear in the BOM, in which case it should be managed or not.

Generated version catalog

In addition to the BOM, Micronaut Platform also generates a version catalog which will be published alongside the BOM. This version catalog is built with the same data as the BOM file, but instead of generating a BOM, it's a catalog. This provides a number of advantages for Gradle users of Micronaut:

  • they can consume the BOM file, but in addition, use the catalog to declare dependencies
  • they can override versions declared in the catalog

A library which alias starts with managed- in a Micronaut catalog, ends up published without the managed- part in the user consumable catalog. For example, for the caffeine example above, the generated catalog would contain:

[versions]
# ...
caffeine = "3.1.8"
# ...
[libraries]
# ...
caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "managed-caffeine" }
# ...

A user wanting to consume this catalog would declare it in their settings file:

dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create("mn") { // "mn" is a name we chose to namespace the imported Micronaut catalog
             from("io.micronaut.platform:micronaut-platform:4.4.2")
        }
    }
}

and then they can use it in their build files like this:

implementation(mn.caffeine) // "mn" comes from the name declared in settings

If a user wants to override the version found in the catalog, they have 2 options:

To override the version for all application sites (that is to say in any build file in their project):

dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create("mn") { // "mn" is a name we chose to namespace the imported Micronaut catalog
             from("io.micronaut.platform:micronaut-platform:4.4.2")
             version("caffeine", "3.0.0")
        }
    }
}

Alternatively, on a single dependency declaration:

implementation(mn.caffeine) {
    version {
        require("3.0.0")
    }
}

It's important to understand that if a user applies both the BOM and the catalog, overriding the versions in the catalog will have no effect unless there's a direct dependency declared.

Building a module against a local version of micronaut-core

Sometimes there may be a breakage in a module due to a change in micronaut-core, and it's useful to be able to build against a local version of micronaut-core so you can bisect to find the breakage.

To do this, add the following to the settings.gradle in your module of interest (requires at least 6.1.0 of the shared settings plugin)

// Relative path to your local copy of micronaut-core
def CORE_PATH = "../micronaut-core"

includeBuild(CORE_PATH) {
    def modules = file("$CORE_PATH/settings.gradle").readLines().findAll {
       it.startsWith('include "') && !it.startsWith('include ":test')
    }.collect { it.substring(9) - '"' }
    
    
    dependencySubstitution {
        modules.each { mod ->
            substitute module("io.micronaut:micronaut-${mod}") using project(":$mod")
        }
    }
}

Or for a kotlin settings.gradle.kts:

// Relative path to your local copy of micronaut-core
val CORE_PATH = "../micronaut-core"

includeBuild(CORE_PATH) {
    val modules = file("$CORE_PATH/settings.gradle").readLines().filter {
        it.startsWith("include \"") && !it.startsWith("include \":test")
    }.map { it.substring(9).replace("\"", "") }

    println(modules)
    dependencySubstitution {
        modules.forEach { mod ->
            substitute(module("io.micronaut:micronaut-${mod}")).using(project(":$mod"))
        }
    }
}

You can then run your tests with

./gradlew check -x generatePomFileForMavenPublication