Migrating from Maven to Gradle

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Gradle 9.8.1 (released on 7 October 2026) and Apache Maven 3.10.0, with Gradle 8.14.6, the last 8.x release, mentioned where it differs. Every example marked as run was executed on 2026-10-16 on macOS (Apple silicon), on Oracle JDK 26 (java version "26", the default JDK of the machine), once with *.gradle.kts files and once with *.gradle files. The Gradle runs used the 9.8.1 distribution, and the Maven runs used 3.10.0 with a local repository created for the page. The Java code is compiled for Java 17 (release 17), so the same builds work on JDK 17 and 21. The Gradle examples are compatible with the configuration cache (org.gradle.configuration-cache=true was set and the second run reused the entry), except the one failure that is shown on purpose. The numbers in the output use the English locale (-Duser.language=en -Duser.country=US), because the machine’s default would print a decimal comma. Long output is shortened with a line that contains only …, and the scratch directory is shown as …. Gradle 8.14.x was not available, so what is said about it comes from the official documentation and is labelled "not run here". Two more things were not run and are labelled the same way: Build Scans (publishing to the Develocity service needs the acceptance of its terms of use) and publishing to a remote repository such as Maven Central; the publishing runs went to a file: repository inside the build directory.

The usual path from Maven to Gradle is to keep the Maven build, add a Gradle build next to it, convert the structure with gradle init, and then repair what the converter cannot know. This page follows that path with a real multi-module build and records every failure on the way. The shared page Migrating Between Tools has the overview of all migration directions and a single-module Maven to Gradle conversion; this page goes deeper into the Gradle side and into the parts of a multi-module POM that the shared page does not cover: the parent POM, dependencyManagement, inherited plugin configuration, Failsafe, Shade and the published POMs. For the vocabulary behind the two tools see Build Automation Fundamentals, Dependency Management Fundamentals and Comparing Ant, Maven & Gradle.

The migration in seven steps

The Gradle user manual recommends this order for any Maven build, and the sections of this page follow it. Do not change the source layout, the artifact coordinates or the Maven build while you migrate: the Gradle build should produce the same artifacts, so that the switch is a matter of the CI pipeline.

flowchart TD A[1. Keep the Maven build and run it in CI] --> B[2. Add the Gradle build beside it: gradle init] B --> C[3. Fix what init could not know: Java release, test framework, filtering, profiles] C --> D[4. Verify dependencies against mvn dependency:tree] D --> E[5. Rebuild integration tests and replace Maven plugins] E --> F[6. Compare artifacts, POMs and test results] F --> G{Same outputs?} G -- no --> C G -- yes --> H[7. Switch CI and releases, then delete the pom.xml files]

The manual also suggests creating a Build Scan of both builds and comparing them. That was not run here (publishing a scan needs the acceptance of the terms of use of the Develocity service); the local --profile report and the dependency reports used below give the same information without leaving the machine. See Performance Profiling & Build Scans.

The Maven build that is migrated

The running example of the section is tidepool, a tide prediction toolkit. As a Maven build it is a parent POM with three modules: tidepool-model (four records), tidepool-engine (a StationReader that uses Commons CSV and a TideEngine) and tidepool-cli (a picocli program that is delivered as a shaded JAR). The parent POM sets the Java release, imports the JUnit BOM, manages the versions of the third-party libraries and of the sibling modules, declares JUnit for every module, activates a profile ci, and attaches the source and Javadoc JARs to every module.

pom.xml (the parent; the list of plugin versions in pluginManagement is shortened)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-parent</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>
  <name>tidepool</name>
  <description>Predicts tide heights from harmonic constituents</description>

  <modules>
    <module>tidepool-model</module>
    <module>tidepool-engine</module>
    <module>tidepool-cli</module>
  </modules>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <deploy.env>dev</deploy.env>
    <commons-csv.version>1.14.1</commons-csv.version>
    <picocli.version>4.7.7</picocli.version>
    <junit.version>6.1.3</junit.version>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.junit</groupId>
        <artifactId>junit-bom</artifactId>
        <version>${junit.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-model</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-engine</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-csv</artifactId>
        <version>${commons-csv.version}</version>
      </dependency>
      <dependency>
        <groupId>info.picocli</groupId>
        <artifactId>picocli</artifactId>
        <version>${picocli.version}</version>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <profiles>
    <profile>
      <id>ci</id>
      <properties>
        <deploy.env>ci</deploy.env>
      </properties>
    </profile>
  </profiles>

  <build>
    <pluginManagement>
      <plugins>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.16.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-resources-plugin</artifactId>
          <version>3.5.0</version>
          <configuration>
            <propertiesEncoding>UTF-8</propertiesEncoding>
          </configuration>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-javadoc-plugin</artifactId>
          <version>3.12.0</version>
          <configuration>
            <doclint>none</doclint>
          </configuration>
        </plugin>
        <!-- surefire 3.6.0, failsafe 3.6.0, jar 3.5.1, source 3.4.0, shade 3.6.2 -->
      </plugins>
    </pluginManagement>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-source-plugin</artifactId>
        <executions>
          <execution>
            <goals><goal>jar-no-fork</goal></goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-javadoc-plugin</artifactId>
        <executions>
          <execution>
            <goals><goal>jar</goal></goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

The engine module uses Surefire and Failsafe: the *Test classes run in the test phase and the *IT class in integration-test, and both receive the location of the data file as a system property. The CLI module filters one resource and shades its dependencies into one executable JAR.

tidepool-engine/pom.xml (the build section; the parent and the dependencies are as expected)
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <configuration>
        <systemPropertyVariables>
          <tidepool.data>${project.parent.basedir}/data/stations.csv</tidepool.data>
        </systemPropertyVariables>
      </configuration>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-failsafe-plugin</artifactId>
      <configuration>
        <systemPropertyVariables>
          <tidepool.data>${project.parent.basedir}/data/stations.csv</tidepool.data>
        </systemPropertyVariables>
      </configuration>
      <executions>
        <execution>
          <goals>
            <goal>integration-test</goal>
            <goal>verify</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>
tidepool-cli/pom.xml (the build section)
<build>
  <resources>
    <resource>
      <directory>src/main/resources</directory>
      <filtering>true</filtering>
    </resource>
  </resources>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <createDependencyReducedPom>false</createDependencyReducedPom>
            <shadedArtifactAttached>true</shadedArtifactAttached>
            <shadedClassifierName>all</shadedClassifierName>
            <filters>
              <filter>
                <artifact>*:*</artifact>
                <excludes>
                  <exclude>META-INF/*.SF</exclude>
                  <exclude>META-INF/*.DSA</exclude>
                  <exclude>META-INF/*.RSA</exclude>
                  <exclude>META-INF/versions/9/module-info.class</exclude>
                </excludes>
              </filter>
            </filters>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>io.tidepool.cli.Main</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>
tidepool-cli/src/main/resources/tidepool.properties
version=${project.version}
env=${deploy.env}

The Main class prints that file when it is started with --version. The Maven build, in the profile ci, with the plugin goals reduced to one line each:

mvn -B -ntp verify -Pci
[INFO] Reactor Build Order:
[INFO] tidepool                                                           [pom]
[INFO] tidepool-model                                                     [jar]
[INFO] tidepool-engine                                                    [jar]
[INFO] tidepool-cli                                                       [jar]
…
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-model ---
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
…
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-engine ---
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
…
[INFO] --- failsafe:3.6.0:integration-test (default) @ tidepool-engine ---
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
…
[INFO] --- shade:3.6.2:shade (default) @ tidepool-cli ---
…
[INFO] Reactor Summary for tidepool 1.0.0:
[INFO] tidepool ........................................... SUCCESS [  1.499 s]
[INFO] tidepool-model ..................................... SUCCESS [  4.345 s]
[INFO] tidepool-engine .................................... SUCCESS [  5.597 s]
[INFO] tidepool-cli ....................................... SUCCESS [  2.165 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
java -Duser.language=en -Duser.country=US -jar tidepool-cli/target/tidepool-cli-1.0.0-all.jar --version
java -Duser.language=en -Duser.country=US -jar tidepool-cli/target/tidepool-cli-1.0.0-all.jar data/stations.csv BOS
tidepool 1.0.0 (ci)
Boston: -0.662 m

This output is the reference for the rest of the page. The Maven side is explained in Maven Inheritance, Aggregation & the Effective POM, Maven Multi-Module Builds & the Reactor and Uber JARs with Shade & Assembly. The POMs, the resource file and the commands of this section are this page’s own example build, written for it rather than taken from a manual.

Step 1 and 2: automatic conversion with gradle init

gradle init run in a directory that has a pom.xml offers to convert it, and the type pom is inferred automatically when the file exists. The conversion reads the effective POM and the effective settings, so inheritance, dependencyManagement and properties are already resolved when it writes the Gradle files, and it handles single-module and multi-module builds. It leaves every Maven file untouched, so both builds can live in the same directory: Maven writes target/, Gradle writes build/, and nothing in the sources changes. The manual lists what is converted: the general metadata (group, name, version, description), the plugins maven-publish, java-library and war, the compiler settings, the external and inter-module dependencies, the repositories (including the local Maven repository), sources and Javadoc packaging, the TestNG runner, exclusions, and global exclusions from the Enforcer plugin; whatever else the POM does is not converted. A Maven compile dependency is mapped to api, which keeps the exposed dependencies of the Maven project and which the manual asks you to revisit after the conversion.

The conversion was run with the wrapper of the build (gradle init itself needs no existing Gradle build; with Gradle installed it is the same command without ./gradlew):

Kotlin DSL
./gradlew init --type pom --dsl kotlin --use-defaults
Groovy DSL
gradle init --type pom --dsl groovy --use-defaults
> Task :init
Maven to Gradle conversion is an incubating feature.
For more information, please refer to https://docs.gradle.org/9.8.1/userguide/migrating_from_maven.html in the Gradle documentation.

BUILD SUCCESSFUL in 1s
1 actionable task: 1 executed

The conversion of the POM type is incubating in Gradle 9.8.1, and the message says so. The options of the task, which ./gradlew help --task init lists, include the ones that matter for a conversion:

./gradlew help --task init
Detailed task information for init

Path
     :init (registered by plugin 'org.gradle.build-init')

Type
     InitBuild (org.gradle.buildinit.tasks.InitBuild)

Options
     --comments     Include clarifying comments in files.
…
     --dsl     Set the build script DSL to be used in generated scripts.
               Available values are:
                    groovy
                    kotlin
…
     --insecure-protocol     How to handle insecure URLs used for Maven Repositories.
                             Available values are:
                                  ALLOW
                                  FAIL
                                  UPGRADE
                                  WARN
…
     --type     Set the type of project to generate.
…

--insecure-protocol is specific to this conversion: a POM that lists a repository with an http:// URL gets the URL commented out (warn, the default), rejected (fail), allowed with allowInsecureProtocol (allow) or rewritten to https (upgrade).

What the conversion produced

For the three-module POM, the conversion wrote these new files (the sources, the POMs and the wrapper files were already there or are not shown):

settings.gradle.kts
gradle.properties
gradle/libs.versions.toml
buildSrc/build.gradle.kts
buildSrc/src/main/kotlin/buildlogic.java-conventions.gradle.kts
tidepool-model/build.gradle.kts
tidepool-engine/build.gradle.kts
tidepool-cli/build.gradle.kts

(The Groovy conversion wrote the same files with the names settings.gradle, buildSrc/build.gradle, buildSrc/src/main/groovy/buildlogic.java-conventions.gradle and build.gradle in each module.) The settings file lists the modules of the <modules> block; the project name is the artifactId of the parent.

settings.gradle.kts (as generated)
rootProject.name = "tidepool-parent"
include(":tidepool-cli")
include(":tidepool-model")
include(":tidepool-engine")

The inheritance of the parent POM became a convention plugin in buildSrc, which every module applies. The generated plugin collects everything the parent declared for all modules:

buildSrc/src/main/kotlin/buildlogic.java-conventions.gradle.kts (as generated)
plugins {
    `java-library`
    `maven-publish`
}

repositories {
    mavenLocal()
    maven {
        url = uri("https://repo.maven.apache.org/maven2/")
    }
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
}

group = "io.tidepool"
version = "1.0.0"
java.sourceCompatibility = JavaVersion.VERSION_1_8

java {
    withSourcesJar()
}

publishing {
    publications.create<MavenPublication>("maven") {
        from(components["java"])
    }
}

tasks.withType<JavaCompile>() {
    options.encoding = "UTF-8"
}

tasks.withType<Javadoc>() {
    options.encoding = "UTF-8"
}
Groovy DSL: buildSrc/src/main/groovy/buildlogic.java-conventions.gradle (as generated, the differences only)
plugins {
    id 'java-library'
    id 'maven-publish'
}

repositories {
    mavenLocal()
    maven {
        url = uri('https://repo.maven.apache.org/maven2/')
    }
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:6.1.3'
}

group = 'io.tidepool'
version = '1.0.0'
java.sourceCompatibility = JavaVersion.VERSION_1_8
…

The module scripts contain what is specific to each module. The conversion put the third-party dependencies, but not the managed versions of the parent, in a version catalog:

tidepool-cli/build.gradle.kts (as generated)
plugins {
    id("buildlogic.java-conventions")
}

dependencies {
    api(project(":tidepool-engine"))
    api(libs.info.picocli.picocli)
}

description = "tidepool-cli"

java {
    withJavadocJar()
}
gradle/libs.versions.toml (as generated)
[versions]
info-picocli-picocli = "4.7.7"
org-apache-commons-commons-csv = "1.14.1"

[libraries]
info-picocli-picocli = { module = "info.picocli:picocli", version.ref = "info-picocli-picocli" }
org-apache-commons-commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "org-apache-commons-commons-csv" }

(The file is shown as text because the code highlighter of the documentation site has no TOML grammar. The two comment lines at the top of the generated file are left out.) gradle.properties contains one line, org.gradle.configuration-cache=true. The wrapper that the task writes has the URL https://services.gradle.org/distributions/gradle-9.8.1-bin.zip but no distributionSha256Sum; add it, as described in Supply Chain Security and Gradle Wrapper:

gradle/wrapper/gradle-wrapper.properties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

What it got wrong, one failure at a time

The generated build is a starting point, and the manual says so. The first ./gradlew build shows the first problem, and each fix shows the next one. The runs below are all on the generated build, with only the fix named in the heading applied.

1. The Java release was lost. The POM sets maven.compiler.release to 17; the conversion wrote java.sourceCompatibility = JavaVersion.VERSION_1_8, and the sources use records:

> Task :tidepool-model:compileJava FAILED
warning: [options] source value 8 is obsolete and will be removed in a future release
warning: [options] target value 8 is obsolete and will be removed in a future release
warning: [options] To suppress warnings about obsolete options, use -Xlint:-options.
…/init-kts/tidepool-model/src/main/java/io/tidepool/model/Constituent.java:4: error: records are not supported in -source 8
public record Constituent(String name, double amplitude, double phase) {
       ^
  (use -source 16 or higher to enable records)
…
4 errors
3 warnings
…

The fix is options.release = 17 in the compile task (or a Java toolchain; see Gradle Toolchains), which also removes the sourceCompatibility line. release is also what the Maven compiler plugin uses when maven.compiler.release is set, and the reason why the code compiles identically in both builds.

2. The JUnit Platform launcher has no version. In the POM, junit-jupiter and the launcher that Surefire adds by itself take their versions from the imported junit-bom. The conversion copied the resolved version of junit-jupiter as a literal, did not import the BOM and did not select the JUnit Platform for the test task. With useJUnitPlatform() and the launcher in testRuntimeOnly, as the shared page recommends, the build fails because the launcher has no version:

* What went wrong:
Configuration cache state could not be cached: field `classpath` of task `:tidepool-model:test` of type `org.gradle.api.tasks.testing.Test`: error writing value of type 'org.gradle.api.internal.file.collections.DefaultConfigurableFileCollection'
> Could not resolve all files for configuration ':tidepool-model:testRuntimeClasspath'.
   > Could not find org.junit.platform:junit-platform-launcher:.
     Required by:
         project ':tidepool-model'
…

The BOM import of Maven becomes a platform: testImplementation(platform("org.junit:junit-bom:6.1.3")), and then the junit-jupiter and junit-platform-launcher dependencies are declared without a version. The section on BOMs below explains the mapping.

3. The integration test runs as a unit test and has no data file. After the two fixes the build compiles. The engine module fails in the test task, because StationReaderIT is in src/test/java like the unit tests, Gradle’s test task runs every class of that source set, and the system property tidepool.data that Surefire and Failsafe passed through systemPropertyVariables is not converted:

> Task :tidepool-engine:test FAILED

StationReaderIT > readsTheShippedStations() FAILED
    java.lang.NullPointerException at StationReaderIT.java:15

2 tests completed, 1 failed

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':tidepool-engine:test'.
> There were failing tests. See the report at: …/init-step1/tidepool-engine/build/reports/tests/test/index.html
…
BUILD FAILED in 1s

4. The filtered resource is copied as it is, and nothing is shaded. With the tests skipped (-x test), the build succeeds, but tidepool-cli/build/resources/main/tidepool.properties is the raw template and the build/libs directory of the CLI has only the thin, sources and Javadoc JARs:

version=${project.version}
env=${deploy.env}

5. The profile has no effect. -Pci is accepted by Gradle without complaint because it is a project property, and nothing reads it. The conversion also did not look at the Surefire, Failsafe, resources or Shade configuration: only the compiler settings, the dependencies and the publishing are converted.

These five points are the usual harvest of a conversion of a real multi-module build, and the rest of the page fixes them in the order of the manual. The conversion also made three decisions that are legitimate but that you should revisit. mavenLocal() is the first repository, which makes the build depend on whatever a previous Maven build installed into ~/.m2/repository; remove it unless you need that interoperability. The repository is declared in buildSrc for every project, while a single declaration in settings.gradle(.kts) with RepositoriesMode.FAIL_ON_PROJECT_REPOS is the current practice (see Repositories & Content Filtering). And buildSrc works, but a build-logic included build is the form recommended for shared build logic (see Convention Plugins & Sharing Build Logic).

The converted build

This is the build after the repairs. The versions come from the catalog, the shared logic from a build-logic included build, and the three modules are short.

Kotlin DSL: settings.gradle.kts
pluginManagement {
    includeBuild("build-logic")
}

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = "tidepool"

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL: settings.gradle
pluginManagement {
    includeBuild('build-logic')
}

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = 'tidepool'

include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
gradle/libs.versions.toml
[versions]
commons-csv = "1.14.1"
picocli = "4.7.7"
junit = "6.1.3"
shadow = "9.6.1"

[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }

[plugins]
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
gradle.properties
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx512m -Duser.language=en -Duser.country=US

The build-logic build has its own settings file, which reads the same catalog, and a build script that applies the plugin for precompiled script plugins:

Kotlin DSL: build-logic/settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic"
Kotlin DSL: build-logic/build.gradle.kts
plugins {
    `kotlin-dsl`
}
Groovy DSL: build-logic/settings.gradle and build-logic/build.gradle
// settings.gradle
dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create('libs') {
            from(files('../gradle/libs.versions.toml'))
        }
    }
}

rootProject.name = 'build-logic'

// build.gradle
plugins {
    id 'groovy-gradle-plugin'
}

The convention plugin replaces the parent POM: everything the parent declared for all modules (coordinates, the compiler release, JUnit for every module, the source and Javadoc JARs, the publication) is in one place and applied by id("tidepool.java-conventions") in each module.

Kotlin DSL: build-logic/src/main/kotlin/tidepool.java-conventions.gradle.kts
plugins {
    `java-library`
    `maven-publish`
}

group = "io.tidepool"
version = "1.0.0"
description = "Predicts tide heights from harmonic constituents"

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

java {
    withSourcesJar()
    withJavadocJar()
}

dependencies {
    // <dependencies> of the parent POM: every module gets JUnit, versions come from the BOM
    testImplementation(platform(libs.findLibrary("junit-bom").get()))
    testImplementation(libs.findLibrary("junit-jupiter").get())
    testRuntimeOnly(libs.findLibrary("junit-platform-launcher").get())
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 17
    options.encoding = "UTF-8"
}

tasks.withType<Javadoc>().configureEach {
    options.encoding = "UTF-8"
    (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:none", true)
}

tasks.withType<Test>().configureEach {
    useJUnitPlatform()
}

publishing {
    publications {
        register<MavenPublication>("maven") {
            from(components["java"])
        }
    }
    repositories {
        maven {
            name = "build"
            url = uri(rootProject.layout.buildDirectory.dir("repo"))
        }
    }
}
Groovy DSL: build-logic/src/main/groovy/tidepool.java-conventions.gradle
plugins {
    id 'java-library'
    id 'maven-publish'
}

group = 'io.tidepool'
version = '1.0.0'
description = 'Predicts tide heights from harmonic constituents'

def libs = extensions.getByType(VersionCatalogsExtension).named('libs')

java {
    withSourcesJar()
    withJavadocJar()
}

dependencies {
    // <dependencies> of the parent POM: every module gets JUnit, versions come from the BOM
    testImplementation platform(libs.findLibrary('junit-bom').get())
    testImplementation libs.findLibrary('junit-jupiter').get()
    testRuntimeOnly libs.findLibrary('junit-platform-launcher').get()
}

tasks.withType(JavaCompile).configureEach {
    options.release = 17
    options.encoding = 'UTF-8'
}

tasks.withType(Javadoc).configureEach {
    options.encoding = 'UTF-8'
    options.addBooleanOption('Xdoclint:none', true)
}

tasks.withType(Test).configureEach {
    useJUnitPlatform()
}

publishing {
    publications {
        maven(MavenPublication) {
            from components.java
        }
    }
    repositories {
        maven {
            name = 'build'
            url = rootProject.layout.buildDirectory.dir('repo')
        }
    }
}

The module scripts. tidepool-model has nothing to add but the plugin; the engine and the CLI are shown in the sections on dependencies, tests, filtering and Shade below.

Kotlin DSL: tidepool-model/build.gradle.kts
plugins {
    id("tidepool.java-conventions")
}
Groovy DSL: tidepool-model/build.gradle
plugins {
    id 'tidepool.java-conventions'
}

The result:

./gradlew clean build -Pci
Reusing configuration cache.
> Task :tidepool-cli:clean
…
> Task :tidepool-cli:processResources
…
> Task :tidepool-model:compileJava
> Task :tidepool-engine:compileJava
> Task :tidepool-cli:compileJava
…
> Task :tidepool-cli:shadowJar
> Task :tidepool-model:test
> Task :tidepool-engine:test
> Task :tidepool-engine:integrationTest
…
> Task :tidepool-cli:build

BUILD SUCCESSFUL in 2s
25 actionable tasks: 25 executed
Configuration cache entry reused.

A second run found everything up to date and reused the configuration cache again:

BUILD SUCCESSFUL in 691ms
22 actionable tasks: 22 up-to-date
Configuration cache entry reused.

The Groovy DSL build, in a separate copy of the directory, produced the same result (BUILD SUCCESSFUL, 32 actionable tasks on the first run with the build-logic tasks, 22 up-to-date on the second) and, as shown in the verification section, byte-identical JARs. Gradle’s task graph for the same module is visible without running it:

./gradlew :tidepool-cli:assemble --dry-run
:tidepool-model:compileJava SKIPPED
:tidepool-engine:compileJava SKIPPED
:tidepool-cli:compileJava SKIPPED
:tidepool-cli:processResources SKIPPED
:tidepool-cli:classes SKIPPED
:tidepool-cli:jar SKIPPED
:tidepool-cli:javadoc SKIPPED
:tidepool-cli:javadocJar SKIPPED
:tidepool-engine:processResources SKIPPED
:tidepool-engine:classes SKIPPED
:tidepool-engine:jar SKIPPED
:tidepool-model:processResources SKIPPED
:tidepool-model:classes SKIPPED
:tidepool-model:jar SKIPPED
:tidepool-cli:shadowJar SKIPPED
:tidepool-cli:sourcesJar SKIPPED
:tidepool-cli:assemble SKIPPED

(The tasks of the build-logic build are left out of the listing.) The order of the unrelated tasks is not fixed. The point is that asking for the CLI also builds the model and the engine, because the CLI depends on them as projects.

The lifecycle and the command line

Maven builds are phases in a fixed order, and each phase runs the goals bound to it. Gradle has no phases, but the base plugin, applied by every JVM plugin, provides lifecycle tasks (empty tasks that depend on the real ones) that line up with the main phases:

Maven phase Gradle task Note

clean

clean

Deletes the build directory of the project.

compile

classes

Compiles all languages and runs processResources, which is also where resource filtering happens.

test

test

Runs the unit tests of the test source set.

package

assemble

Builds the archives of the project: the JAR, and here the sources, Javadoc and -all JARs.

verify

check

Runs every verification task that is attached to it; integration tests have to be attached by hand (see below).

install

publishToMavenLocal

Needed only to hand artifacts to a Maven build; Gradle builds share outputs as project dependencies or through included builds.

deploy

publish

Publishes every publication to every repository; publishNamePublicationToRepoNameRepository publishes one of them.

build runs assemble and check. A lifecycle task is not special: you create your own by writing a task without action that depends on the ones you want, and the mvn verify-style run of "everything except the slow tests" is a lifecycle task of this kind. The mapping of the command line options is also direct for the common ones:

Maven Gradle Note

mvn verify -pl tidepool-cli -am

./gradlew :tidepool-cli:build

Project dependencies are built automatically; no -am is needed (see the dry run above). Naming a task of one project runs that task and the tasks it needs in the other projects.

mvn verify -DskipTests

./gradlew build -x test -x integrationTest

-x removes a task from the task graph.

mvn -o

./gradlew --offline

Use the dependency cache only.

mvn -U

./gradlew --refresh-dependencies

Ignore cached dependency metadata and artifacts.

mvn -T 4

org.gradle.parallel=true or --parallel

Gradle also runs the independent tasks of a single project in parallel when the configuration cache is on.

mvn -P ci

-Pci (a project property)

Different meaning, explained in the section on profiles.

mvn -Dprop=value

-Dprop=value (a system property) or -Pprop=value (a project property)

Gradle distinguishes the two kinds of property; see the section on profiles and properties.

mvn -q, -X

--quiet, --debug

Also --info and --warn. See Directories & Logging.

.mvn/maven.config, .mvn/jvm.config

gradle.properties (org.gradle.jvmargs, org.gradle.parallel)

See Build Environment & Properties.

mvnw

gradlew

See Gradle Wrapper and Maven Wrapper.

Running ./gradlew :tidepool-cli:build -x test -x integrationTest --offline on the converted build, with the configuration cache, took 1 second and executed one task; the other 19 were up to date. The Maven phases and the fixed order are described in Maven Build Lifecycle, and the Gradle model in Gradle Build Lifecycle and Command-Line Interface.

Dependencies

Gradle uses the same coordinates as Maven (group, artifact, version, classifier) and the same repositories, so the dependency part of a migration is mostly a change of notation. What changes is the vocabulary (scopes are configurations), the place where versions are managed, and one rule of conflict resolution.

Scopes and configurations

Maven scope Gradle configuration Note

compile

implementation or api

api exists only with the java-library plugin and is part of the compile classpath of consumers; use implementation by default. gradle init writes api for all of them.

runtime

runtimeOnly

provided

compileOnly

The war plugin adds providedCompile and providedRuntime, which keep the dependency out of the WAR but on the test classpaths.

test

testImplementation and testRuntimeOnly

Gradle separates what the tests need to compile from what they need only to run (the JUnit launcher is the second kind).

import (in dependencyManagement)

platform(...)

See the section on BOMs.

optional

Feature variants

Gradle ignores the optional dependencies of other modules, like Maven; to publish an optional dependency, declare a feature variant (see Java Projects).

Gradle separates the dependencies a module needs to be built from those a module that uses it needs, which Maven does not: published POMs of Maven projects typically contain dependencies that consumers do not need. In the tidepool conversion, the engine uses Commons CSV only inside StationReader, and its public methods only mention types of the model, so the engine declares api(project(":tidepool-model")) and implementation(libs.commons.csv). The published POM then lists Commons CSV with the runtime scope, not compile (see the section on publishing).

Kotlin DSL: tidepool-engine/build.gradle.kts
plugins {
    id("tidepool.java-conventions")
}

dependencies {
    api(project(":tidepool-model"))
    implementation(libs.commons.csv)
}

val stationsFile = rootProject.layout.projectDirectory.file("data/stations.csv")

// Surefire ran *Test, Failsafe ran *IT: the same split on the same source set
tasks.test {
    exclude("**/*IT.class")
    systemProperty("tidepool.data", stationsFile.asFile.path)
}

val integrationTest = tasks.register<Test>("integrationTest") {
    description = "Runs the *IT classes (Failsafe)."
    group = "verification"
    testClassesDirs = sourceSets.test.get().output.classesDirs
    classpath = sourceSets.test.get().runtimeClasspath
    include("**/*IT.class")
    systemProperty("tidepool.data", stationsFile.asFile.path)
    shouldRunAfter(tasks.test)
}

tasks.check {
    dependsOn(integrationTest)
}
Groovy DSL: tidepool-engine/build.gradle
plugins {
    id 'tidepool.java-conventions'
}

dependencies {
    api project(':tidepool-model')
    implementation libs.commons.csv
}

def stationsFile = rootProject.layout.projectDirectory.file('data/stations.csv')

// Surefire ran *Test, Failsafe ran *IT: the same split on the same source set
tasks.named('test') {
    exclude '**/*IT.class'
    systemProperty 'tidepool.data', stationsFile.asFile.path
}

def integrationTest = tasks.register('integrationTest', Test) {
    description = 'Runs the *IT classes (Failsafe).'
    group = 'verification'
    testClassesDirs = sourceSets.test.output.classesDirs
    classpath = sourceSets.test.runtimeClasspath
    include '**/*IT.class'
    systemProperty 'tidepool.data', stationsFile.asFile.path
    shouldRunAfter tasks.named('test')
}

tasks.named('check') {
    dependsOn integrationTest
}

(The test part of this script is explained in the section on tests.) The dependency list of the CLI, as Maven and Gradle resolve it, is the same:

mvn verify org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree -pl tidepool-cli -am -Dscope=runtime -DskipTests
[INFO] io.tidepool:tidepool-cli:jar:1.0.0
[INFO] +- io.tidepool:tidepool-engine:jar:1.0.0:compile
[INFO] |  +- io.tidepool:tidepool-model:jar:1.0.0:compile
[INFO] |  \- org.apache.commons:commons-csv:jar:1.14.1:compile
[INFO] |     +- commons-io:commons-io:jar:2.20.0:compile
[INFO] |     \- commons-codec:commons-codec:jar:1.19.0:compile
[INFO] \- info.picocli:picocli:jar:4.7.7:compile
./gradlew :tidepool-cli:dependencies --configuration runtimeClasspath
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-engine'
|    +--- project ':tidepool-model'
|    \--- org.apache.commons:commons-csv:1.14.1
|         +--- commons-io:commons-io:2.20.0
|         \--- commons-codec:commons-codec:1.19.0
\--- info.picocli:picocli:4.7.7

On the compile classpath, the Gradle report shows only project ':tidepool-engine' with project ':tidepool-model' and info.picocli:picocli:4.7.7: Commons CSV, an implementation detail of the engine, is not visible to the CLI at compile time. The Maven tree cannot show that distinction.

Repositories

Maven has an implicit repository, Central; Gradle has none, so every build must declare at least one. The equivalent of Maven’s default is mavenCentral(), declared once in settings.gradle(.kts) as above. A <repository> element becomes maven { url = uri("...") }. A <mirror> of settings.xml has no direct counterpart in the build file; Gradle 9.8.0 can reuse the mirror settings of Maven when org.gradle.mirror.maven.settings=true is set, which is described, with the results of a real run, in Repositories & Content Filtering. mavenLocal() resolves against ~/.m2/repository; the manual says it is for interoperating with Maven builds only, and it should not be left in a migrated build that does not need it, because it makes the result depend on what other builds installed.

Managed versions: properties, dependencyManagement and the catalog

A parent POM manages versions in two places: properties such as <commons-csv.version> and the <dependencyManagement> block that uses them. Gradle has three tools for the same job, and they are not interchangeable:

Maven idiom Gradle replacement

A version property used by one or two dependencies

An entry in the [versions] table of gradle/libs.versions.toml, referenced with version.ref. The catalog is the same for every project of the build, and for the included build-logic build.

<dependencyManagement> entry for a third-party library, with the dependency declared without a version in the module

A catalog library (libs.commons.csv) with the version; the module declares the library. If the version must also apply to transitive dependencies, add a dependency constraint (constraints { }) or a platform.

<dependencyManagement> entry for a sibling module

Nothing: project(":tidepool-model") has no version, because the project is part of the build.

import of a BOM

platform(...) or enforcedPlatform(...).

A parent POM of a company that only manages versions (a BOM that other builds import)

The java-platform plugin, which publishes a BOM.

The version properties of the parent POM became the [versions] table of the catalog shown above. A value that must differ between builds comes from a Gradle property read with providers.gradleProperty in the build script, not from the catalog. See Version Catalogs.

BOMs, platforms and java-platform

The import of the JUnit BOM, in the convention plugin above, is testImplementation(platform(libs.findLibrary("junit-bom").get())). A platform adds the version constraints of the BOM to the dependency graph; junit-jupiter and the launcher then need no version of their own. platform() takes the <dependencyManagement> constraints of the POM and ignores the <dependencies> block of the BOM; enforcedPlatform() makes the constraints strict, so that a higher version requested elsewhere in the graph cannot override them. The Maven scope of a BOM import is always import; in Gradle the import is a normal dependency declaration in the configuration where you want the constraints (implementation, testImplementation, or both).

The other direction, publishing a BOM from a build, replaces a <packaging>pom</packaging> parent that only has a dependencyManagement section. The java-platform plugin produces it. This minimal build was run in both DSLs:

Kotlin DSL: build.gradle.kts
plugins {
    `java-platform`
    `maven-publish`
}

group = "io.tidepool"
version = "1.0.0"

dependencies {
    constraints {
        api("io.tidepool:tidepool-model:1.0.0")
        api("io.tidepool:tidepool-engine:1.0.0")
        api("org.apache.commons:commons-csv:1.14.1")
        api("info.picocli:picocli:4.7.7")
    }
}

publishing {
    publications {
        register<MavenPublication>("maven") {
            from(components["javaPlatform"])
        }
    }
    repositories {
        maven {
            name = "build"
            url = uri(layout.buildDirectory.dir("repo"))
        }
    }
}
Groovy DSL: build.gradle
plugins {
    id 'java-platform'
    id 'maven-publish'
}

group = 'io.tidepool'
version = '1.0.0'

dependencies {
    constraints {
        api 'io.tidepool:tidepool-model:1.0.0'
        api 'io.tidepool:tidepool-engine:1.0.0'
        api 'org.apache.commons:commons-csv:1.14.1'
        api 'info.picocli:picocli:4.7.7'
    }
}

publishing {
    publications {
        maven(MavenPublication) {
            from components.javaPlatform
        }
    }
    repositories {
        maven {
            name = 'build'
            url = layout.buildDirectory.dir('repo')
        }
    }
}
./gradlew publish

build/repo/io/tidepool/tidepool-bom/1.0.0/ then holds tidepool-bom-1.0.0.pom and tidepool-bom-1.0.0.module, and the POM is what a Maven consumer expects from a BOM:

build/repo/io/tidepool/tidepool-bom/1.0.0/tidepool-bom-1.0.0.pom (from the modelVersion element on)
  <modelVersion>4.0.0</modelVersion>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-bom</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-model</artifactId>
        <version>1.0.0</version>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-engine</artifactId>
        <version>1.0.0</version>
      </dependency>
      <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-csv</artifactId>
        <version>1.14.1</version>
      </dependency>
      <dependency>
        <groupId>info.picocli</groupId>
        <artifactId>picocli</artifactId>
        <version>4.7.7</version>
      </dependency>
    </dependencies>
  </dependencyManagement>

The manual recommends the same plugin for the case in which a multi-module build shares versions through a platform project, which the other projects consume with platform(project(":platform")). For a single build with a catalog this is not needed. See Platforms, Constraints & Rich Versions and Maven Dependency Management & BOMs.

The one real semantic difference: which version wins

Maven resolves a conflict by nearest definition: the version declared closest to the root of the tree wins, and a version in your own POM always wins over a transitive one. Gradle picks the highest version of the module in the graph. A converted build can therefore resolve a different version even if every declaration is identical. This was run on a small project that declares Commons CSV 1.14.1 (which needs commons-io 2.20.0) and, directly, commons-io 2.11.0:

mvn org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree
[INFO] io.tidepool:mediation:jar:1.0.0
[INFO] +- org.apache.commons:commons-csv:jar:1.14.1:compile
[INFO] |  \- commons-codec:commons-codec:jar:1.19.0:compile
[INFO] \- commons-io:commons-io:jar:2.11.0:compile
Kotlin DSL: build.gradle.kts
plugins {
    `java-library`
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.apache.commons:commons-csv:1.14.1")
    implementation("commons-io:commons-io:2.11.0")
}
./gradlew dependencies --configuration runtimeClasspath
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-csv:1.14.1
|    +--- commons-io:commons-io:2.20.0
|    \--- commons-codec:commons-codec:1.19.0
\--- commons-io:commons-io:2.11.0 -> 2.20.0

Maven used the older 2.11.0 declared in the POM; Gradle used 2.20.0 (2.11.0 -> 2.20.0 in the report) and said why with dependencyInsight:

commons-io:commons-io:2.20.0
…
   Selection reasons:
      - By conflict resolution: between versions 2.20.0 and 2.11.0
…

If the old version must stay (a library that breaks with the new one, a security policy), say so in the build: a strictly version makes the declaration a hard requirement, and it overrides the request of the transitive dependency:

Kotlin DSL
dependencies {
    implementation("org.apache.commons:commons-csv:1.14.1")
    implementation("commons-io:commons-io") {
        version {
            strictly("2.11.0")
        }
    }
}
Groovy DSL
dependencies {
    implementation 'org.apache.commons:commons-csv:1.14.1'
    implementation('commons-io:commons-io') {
        version {
            strictly '2.11.0'
        }
    }
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-csv:1.14.1
|    +--- commons-io:commons-io:2.20.0 -> 2.11.0
|    \--- commons-codec:commons-codec:1.19.0
\--- commons-io:commons-io:{strictly 2.11.0} -> 2.11.0

The Groovy DSL variants of the three builds in this section (implementation 'commons-io:commons-io:2.11.0', version { strictly '2.11.0' } and the exclusion below) were run too and printed the same reports. The list of dependencies, compared at the end of a migration with mvn dependency:tree or dependency:list, is how you find these differences before production does. The mechanisms are in Dependency Resolution, Conflicts & Capabilities and the Maven rule in Maven Dependencies, Scopes & Mediation.

Exclusions

A Maven <exclusion> removes a transitive dependency from one declaration. The Gradle form is the same, but the manual advises you to understand why the exclusion exists before you carry it over: an exclusion that was only there to select a version is better expressed as a constraint or a strictly version, and an exclusion that removes a module for a reason unrelated to versions can be attached to the whole configuration. This was run for the exclusion of commons-codec from Commons CSV:

[INFO] io.tidepool:mediation:jar:1.0.0
[INFO] +- org.apache.commons:commons-csv:jar:1.14.1:compile
[INFO] \- commons-io:commons-io:jar:2.11.0:compile
Kotlin DSL
implementation("org.apache.commons:commons-csv:1.14.1") {
    exclude(group = "commons-codec", module = "commons-codec")
}
Groovy DSL
implementation('org.apache.commons:commons-csv:1.14.1') {
    exclude group: 'commons-codec', module: 'commons-codec'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-csv:1.14.1
|    \--- commons-io:commons-io:2.20.0
\--- commons-io:commons-io:2.11.0 -> 2.20.0

(The first output is the Maven tree with the exclusion in the POM, the second the Gradle report with the exclusion; the Groovy block printed the same report.) The Gradle report keeps the version difference of the previous section: excluding a module does not change which version of the others wins. See Resolution Rules & Substitution for the other tools (substitution, capabilities, eachDependency).

Multi-module builds and inheritance

A Maven <modules> block is an include(...) in settings.gradle(.kts) (the conversion did this for the three modules). The dependency of one module on another becomes project(":name"), and a project dependency is both the ordering and the artifact reference; the order of the reactor, which Maven derives from the dependencies, is derived in the same way. ./gradlew projects lists the result:

./gradlew projects
Projects:

------------------------------------------------------------
Root project 'tidepool'
------------------------------------------------------------

Location: …/groovy

Project hierarchy:

Root project 'tidepool'
+--- Project ':tidepool-cli' - Predicts tide heights from harmonic constituents
+--- Project ':tidepool-engine' - Predicts tide heights from harmonic constituents
…

Inheritance has no direct counterpart. In Maven, a child POM inherits dependencies, plugins, properties and configuration from its parent in a fixed model. In Gradle the same sharing is a convention plugin (a plugin that configures other plugins), and the allprojects { } and subprojects { } blocks that injected configuration in older builds are the form the manual discourages, because they couple projects to each other. The parts of the parent POM map as follows:

Parent POM Convention plugin or settings

<groupId>, <version>

group and version in the convention plugin (or the root gradle.properties)

<dependencies> (inherited by every module)

The dependencies { } block of the plugin

<dependencyManagement>

Catalog, platform or constraints (see above)

<pluginManagement> (plugin versions)

The versions in the [plugins] table of the catalog and in the build.gradle.kts of build-logic; each plugin has a version in exactly one place

<build><plugins> (plugins applied to every module)

plugins { } in the convention plugin

<properties>

gradle.properties, the catalog, or local vals

<profiles>

Project properties tested in the scripts (see below)

<repositories>

dependencyResolutionManagement { repositories { } } in the settings file

A module that does not belong to the parent (an aggregator without inheritance, or a parent in another repository) maps to a build with its own convention plugin, published to a plugin repository or included as a build with includeBuild. The shared logic of this build is in the included build build-logic; the conversion’s buildSrc is the same idea in a fixed location, and the page on convention plugins compares the two. See Multi Project Builds, Convention Plugins & Sharing Build Logic and Composite Builds.

Profiles and properties

A Maven profile is, as the manual says, a limited if statement. Gradle does not need formal profiles because the build script is a program: it can test a project property, an environment variable, the operating system or the JDK. The profile ci of tidepool only sets the property deploy.env; the equivalent is a project property:

Kotlin DSL: tidepool-cli/build.gradle.kts (the part about the profile and the filtering)
// Maven profile "ci" -> a project property: ./gradlew build -Pci
val deployEnv = providers.gradleProperty("ci").map { "ci" }.orElse("dev")
val projectVersion = version.toString()
Groovy DSL: tidepool-cli/build.gradle (the part about the profile and the filtering)
// Maven profile "ci" -> a project property: ./gradlew build -Pci
def deployEnv = providers.gradleProperty('ci').map { 'ci' }.orElse('dev')
def projectVersion = version.toString()

providers.gradleProperty is a lazy, configuration-cache aware way of reading a project property: Gradle remembers that the build used the property and discards the cached configuration if its value changes. A -Pci on the command line has no value (-Pci sets it to an empty string), so the map ignores the value and tests only the presence. The usual forms are -Penv=ci read with orElse("dev"), or a switch in gradle.properties of a CI image.

Three differences between the two systems cause most surprises:

  • -P means a profile in Maven and a project property in Gradle. mvn -Pci activates all the settings of the profile; ./gradlew -Pci changes nothing unless a script reads the property. A converted build ignores the profiles silently; the conversion does not warn about them.

  • Profile activation by the JDK, the operating system, the presence of a file or a property (<activation>) becomes plain code. The manual shows a technique with profile scripts (profile-test.gradle, profile-prod.gradle) applied with apply(from = ...) according to a property, and warns that conditions in the build make it harder to understand; prefer several tasks of the same type (the manual’s example is that the Maven Publish plugin creates one publish task per publication and repository) over a switch.

  • The properties of the POM that the maven-resources-plugin reads (${project.version}, ${deploy.env}) exist only in Maven. The Gradle replacement is shown in the next section; the environment of the build (JVM options, parallelism, caches) goes into gradle.properties and is described in Build Environment & Properties. A gradle.properties in the project root is overridden by the one in the Gradle user home and by command line options; a property set in the build script wins over every external file.

The profiles of a settings.xml (credentials, mirrors, repository URLs for a company network) are a user-level matter: credentials go to ~/.gradle/gradle.properties or to environment variables, and the mirror to the setting described in Repositories & Content Filtering. See Maven Profiles and Maven settings.xml & Password Encryption.

Resource filtering

The filtered resource of the CLI is tidepool.properties, with the Maven placeholders ${project.version} and ${deploy.env}. The Gradle task processResources does the same copy, and its expand method treats the file as a Groovy template. If you keep the file unchanged during the parallel period, both builds must be able to fill the placeholders. The trick is that the template engine resolves ${project.version} as property version of an object called project, so the binding can provide maps with those names:

Kotlin DSL: tidepool-cli/build.gradle.kts
tasks.processResources {
    val env = deployEnv
    val ver = projectVersion
    inputs.property("deployEnv", env)
    inputs.property("projectVersion", ver)
    filesMatching("tidepool.properties") {
        expand(
            "project" to mapOf("version" to ver),
            "deploy" to mapOf("env" to env.get())
        )
    }
}
Groovy DSL: tidepool-cli/build.gradle
tasks.named('processResources') {
    def env = deployEnv
    def ver = projectVersion
    inputs.property('deployEnv', env)
    inputs.property('projectVersion', ver)
    filesMatching('tidepool.properties') {
        expand(project: [version: ver], deploy: [env: env.get()])
    }
}

The two inputs.property lines declare the values as inputs, so that a build with another profile re-runs the task instead of reusing the output. Without the property, processResources would be up to date after a change from dev to ci. Both values were checked:

./gradlew :tidepool-cli:processResources
cat tidepool-cli/build/resources/main/tidepool.properties
./gradlew :tidepool-cli:processResources -Pci
cat tidepool-cli/build/resources/main/tidepool.properties
version=1.0.0
env=dev
> Task :tidepool-cli:processResources
version=1.0.0
env=ci

(The first command’s task line, UP-TO-DATE, and the build summaries are left out.) The second run executed the task, as the line shows.

The same code written the obvious way fails with the configuration cache, which is why the val env = deployEnv lines exist. The first version of the script read the script-level val deployEnv and val projectVersion inside the filesMatching action. The action is stored in the configuration cache, and a reference to a top-level val of a Kotlin script is a reference to the script object, which cannot be stored:

FAILURE: Build failed with an exception.

* What went wrong:
Configuration cache problems found in this build.

1 problem was found storing the configuration cache.
- Task `:tidepool-cli:processResources` of type `org.gradle.language.jvm.tasks.ProcessResources`: cannot serialize Gradle script object references as these are not supported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:disallowed_types

The remedy is to copy the values into local variables of the configuration block, as in the script above (the failing version was the same without the two local val lines). Reading project.version or other project properties in the action, at execution time, is a related error: it is deprecated by Gradle 9.x and will fail in Gradle 10; read it into a variable first.

Two details differ from Maven. expand treats the whole file as a template, so every $ is interpreted; a file with literal dollars needs filter with org.apache.tools.ant.filters.ReplaceTokens (the @token@ style that Maven also supports). And the Maven <propertiesEncoding> setting corresponds to nothing here: Gradle copies with the platform encoding for expand, so set filteringCharset = "UTF-8" on processResources if the files contain non-ASCII characters. See Working with Files and Maven Resources & Filtering.

Tests and integration tests

Surefire runs the *Test classes in the test phase, and Failsafe runs the *IT classes in integration-test, then checks the result in verify; the split exists so that a failing integration test does not skip the cleanup that follows it. Gradle runs the test source set with the Test task, and check depends on it. Three things are needed:

  • Select the JUnit Platform for every Test task (useJUnitPlatform(), in the convention plugin) and give the test runtime classpath the launcher. Surefire brings both by itself.

  • Pass the system properties that the plugin configuration passed. systemPropertyVariables is systemProperty(...) on the task; the data file location comes from the root project directory instead of the project.parent.basedir of the POM.

  • Keep integration tests apart from the unit tests.

For the period in which both builds must work on the same sources, the engine module above uses the file naming convention: the test task excludes **/*IT.class, and a second Test task, integrationTest, reuses the class directories and the classpath of the test source set and includes only those classes, and check depends on it. That mimics Surefire plus Failsafe in the Maven layout. The recommended form for a finished migration is the JVM test suite plugin, with the integration tests in src/integrationTest/java, where check depends on the suite and each suite has its own dependencies; the manual’s integration-test section describes it and Migrating Between Tools shows a complete example. Failsafe keeps the build alive until verify; a Gradle suite fails the build unless ignoreFailures is set. Cleanup that must always run goes into a task attached with finalizedBy. See Testing JVM Projects, Testing with Surefire and Maven Integration Testing with Failsafe.

The results were compared by reading the XML reports of the runs. Maven’s Surefire reports are in target/surefire-reports and Failsafe’s in target/failsafe-reports, and Gradle’s are in build/test-results/<task>:

Class Maven Gradle

io.tidepool.model.StationTest

1 test, 0 failures (Surefire)

1 test, 0 failures (test)

io.tidepool.engine.TideEngineTest

1 test, 0 failures (Surefire)

1 test, 0 failures (test)

io.tidepool.engine.StationReaderIT

1 test, 0 failures (Failsafe)

1 test, 0 failures (integrationTest)

Plugins: Maven plugins and their Gradle counterparts

The conversion does not carry Maven plugins over; the manual’s rule is to look at what a plugin does, not at how it does it. Plugins fall into four groups: those that Gradle does by itself, those with a counterpart plugin, those you do not need, and those you must rewrite.

Maven plugin In Gradle Note

maven-compiler-plugin

java / java-library plugin, JavaCompile with options.release or a toolchain

Built in; see Java Projects and Gradle Toolchains.

maven-resources-plugin

processResources and processTestResources

Built in; expand and filter replace filtering.

maven-surefire-plugin

The test task

Built in.

maven-failsafe-plugin

A Test task or a JVM test suite attached to check

See above.

maven-jar-plugin

The jar task

The manifest is configured on the task.

maven-source-plugin, maven-javadoc-plugin

java { withSourcesJar(); withJavadocJar() }

Built in; the maven-publish plugin does not publish them unless you ask.

maven-shade-plugin, maven-assembly-plugin

The Shadow plugin (com.gradleup.shadow) for a shaded JAR; the distribution, application and java-library-distribution plugins for an assembly

Shadow is a third-party plugin from the Gradle Plugin Portal; assemblies are not converted by gradle init.

maven-install-plugin, maven-deploy-plugin

publishToMavenLocal, publish of maven-publish

See Publishing.

maven-release-plugin, versions-maven-plugin

Third-party release and version plugins; or a CI-driven version in gradle.properties

Gradle has no release plugin in the core. See Releasing with Maven.

maven-enforcer-plugin

Usually nothing: dependency constraints, a strictly version, dependency locking and dependency verification

The conversion turns the Enforcer’s global exclusions into exclusions, nothing else.

maven-dependency-plugin (tree, analyze, copy-dependencies)

dependencies, dependencyInsight, and a Copy task over a configuration

The reports are built in.

maven-antrun-plugin

The ant object of the build script

See Ant Integration.

exec-maven-plugin, build-helper-maven-plugin

Exec and JavaExec tasks; extra source sets and source directories

Source sets replace most uses of the build-helper plugin.

maven-checkstyle-plugin, maven-pmd-plugin, jacoco-maven-plugin

The core checkstyle, pmd and jacoco plugins

The configuration blocks carry over; the executions and phases are not needed because the tasks attach themselves to check.

maven-wrapper-plugin

The wrapper task

./gradlew wrapper --gradle-version 9.8.1; see Gradle Wrapper.

maven-archetype-plugin

gradle init (templates)

The init templates are for new projects.

A plugin written for the project

A custom task type or a binary plugin

See below.

The manual’s advice for custom plugins is to separate those that use the Maven project object from those that do not. A plugin that does not use it can be reimplemented as a custom task type with inputs and outputs that mirror the mojo parameters, and the execution logic becomes the task action; a plugin that needs the Maven model has to be redesigned, because the two build models are too different to transcribe code from one into the other. See Writing Custom Gradle Tasks, Binary Plugins and Writing Maven Plugins.

Shade becomes Shadow

The maven-shade-plugin configuration of the CLI has a Gradle counterpart in the Shadow plugin, a third-party plugin that is maintained outside of Gradle. Version 9.6.1 was current on the Plugin Portal and was used for the run (with the other plugin of the catalog), and it applies to Java projects with the shadowJar task. The task is added to assemble, the classifier all is set like the Maven shadedClassifierName, the signature files are excluded like in the filter, and the main class goes into the manifest:

Kotlin DSL: tidepool-cli/build.gradle.kts
plugins {
    id("tidepool.java-conventions")
    alias(libs.plugins.shadow)
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation(libs.picocli)
}

// maven-shade-plugin -> the Shadow plugin
tasks.shadowJar {
    archiveClassifier = "all"
    manifest {
        attributes("Main-Class" to "io.tidepool.cli.Main")
    }
    exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA", "META-INF/versions/9/module-info.class")
}
Groovy DSL: tidepool-cli/build.gradle
plugins {
    id 'tidepool.java-conventions'
    alias(libs.plugins.shadow)
}

dependencies {
    implementation project(':tidepool-engine')
    implementation libs.picocli
}

// maven-shade-plugin -> the Shadow plugin
tasks.named('shadowJar') {
    archiveClassifier = 'all'
    manifest {
        attributes('Main-Class': 'io.tidepool.cli.Main')
    }
    exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA', 'META-INF/versions/9/module-info.class'
}

The result runs like the Maven JAR:

java -Duser.language=en -Duser.country=US -jar tidepool-cli/build/libs/tidepool-cli-1.0.0-all.jar --version
java -Duser.language=en -Duser.country=US -jar tidepool-cli/build/libs/tidepool-cli-1.0.0-all.jar data/stations.csv BOS
tidepool 1.0.0 (ci)
Boston: -0.662 m

Relocation, the ServicesResourceTransformer of Shade and the merging of service files have counterparts in Shadow (relocate, mergeServiceFiles()); they were not used in this build and were not run. The plain Gradle alternative, a Jar task that unpacks the runtime classpath, is on Java Projects and has the limits listed there. The Maven side, with relocation, is on Uber JARs with Shade & Assembly.

Verifying that the migration worked

The manual makes this the most important step: a Gradle build that produces the same outputs lets you switch with confidence. A good verification compares what is shipped, what is resolved and what is tested. All three were run on the tidepool build with the Maven build in maven/ and the Gradle build in kts/ and groovy/.

Artifacts

The JARs of the two builds were unpacked and compared entry by entry with diff -rq (the thin JARs of the three modules and the shaded JAR of the CLI):

--- tidepool-cli-1.0.0-all.jar
Files a/META-INF/MANIFEST.MF and b/META-INF/MANIFEST.MF differ
Only in a/META-INF/maven: io.tidepool
--- tidepool-cli-1.0.0.jar
Files a/META-INF/MANIFEST.MF and b/META-INF/MANIFEST.MF differ
Only in a/META-INF: maven
--- tidepool-engine-1.0.0.jar
Files a/META-INF/MANIFEST.MF and b/META-INF/MANIFEST.MF differ
Only in a/META-INF: maven
--- tidepool-model-1.0.0.jar
Files a/META-INF/MANIFEST.MF and b/META-INF/MANIFEST.MF differ
Only in a/META-INF: maven

(a is the Maven JAR and b the Gradle JAR.) Every class and the filtered tidepool.properties are byte-identical. There are only two differences, and both are expected. Maven adds META-INF/maven/<group>/<artifact>/pom.xml and pom.properties to every JAR; Gradle does not. And the manifests differ:

Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 17
Build-Jdk-Spec: 26
Main-Class: io.tidepool.cli.Main

is the manifest of the Maven shaded JAR, and

Manifest-Version: 1.0
Main-Class: io.tidepool.cli.Main
Multi-Release: true

is the manifest of the Gradle -all JAR. Gradle’s manifest has no Created-By and Build-Jdk-Spec entries. It does have Multi-Release: true, which Shadow writes because the shaded libraries declare it (the manifests of commons-csv, commons-io, commons-codec and picocli all contain Multi-Release: true). Both JARs contain the same META-INF/versions entries, but Maven’s manifest does not declare the JAR as multi-release, which means the JVM reads those entries as ordinary files; this made no difference to the output of the program here. One more difference was found by the comparison and is worth recording: in this run, the Main-Class attribute set on the shadowJar task was also written to the manifest of the thin tidepool-cli-1.0.0.jar (removing the manifest block removed it from both JARs). It does not harm, but a library JAR that has a Main-Class is not what the Maven build produced. The manual makes the same point: small differences such as the contents of a manifest can break a deployment, so check the ones that your deployment depends on. The count of files in the -all JARs was 902 for Maven and 896 for Gradle, the six extra entries of Maven being the three pom.xml and the three pom.properties files.

The Groovy DSL build and the Kotlin DSL build produce the same JARs: the -all JAR and the thin JAR of the CLI were compared in the same way and were identical (after both had been built with -Pci).

Published POMs

./gradlew publish wrote to build/repo the JARs, a .pom, a .module file (Gradle Module Metadata) and maven-metadata.xml for each module. The .module file is an extra that a Maven consumer ignores; a Gradle consumer reads it. The files for the engine were:

io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0-javadoc.jar
io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0-sources.jar
io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.jar
io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.module
io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom
io/tidepool/tidepool-engine/maven-metadata.xml

(The checksum files are left out. The CLI additionally published tidepool-cli-1.0.0-all.jar, like the Maven build, which attaches the shaded JAR.) The POM that Gradle generates for the engine is a standalone file, with no parent, resolved versions and scopes that reflect how the dependencies are used:

build/repo/io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <!-- This module was also published with a richer model, Gradle metadata,  -->
  <!-- which should be used instead. Do not delete the following line which  -->
  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
  <!-- that they should prefer consuming it instead. -->
  <!-- do_not_remove: published-with-gradle-metadata -->
  <modelVersion>4.0.0</modelVersion>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-engine</artifactId>
  <version>1.0.0</version>
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
      <version>1.0.0</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

The POM that Maven 3.10.0 installed for the same module is the source POM (mvn install does not flatten it in Maven 3), starting with:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-parent</artifactId>
    <version>1.0.0</version>
  </parent>
  <artifactId>tidepool-engine</artifactId>
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
    </dependency>
…

Two consequences matter to the migration. First, consumers of the Gradle POM see commons-csv as a runtime dependency, because it was declared with implementation: a Maven project that compiled against Commons CSV through the engine’s old compile scope no longer gets it on its compile classpath. That is the intended effect, but it is a change of the contract, and you should tell the consumers or keep api for the libraries whose types leak. Second, the POM does not carry the name, description, URL, licenses and developers that Maven Central requires; add them through publishing.publications.maven.pom { } (see Publishing and Publishing to Maven Central). The POM was only published to the local directory: publishing to Maven Central or any other remote repository was not run here.

Dependencies and tests

The dependency lists of the two builds were compared above: the runtime tree of the CLI is the same in both tools. For a larger build, compare the whole resolved list per configuration and look at every difference; the mediation difference of the section on dependencies is the most common source. Dependency locking and dependency verification (see Dependency Locking, Verification & Caching) let you freeze the result after the comparison and are Gradle’s replacement for the Enforcer rules. The test results are the table in the section on tests. The comparison of the shared page, Migrating Between Tools, applies to any size of build.

Both builds in the same directory

The two builds can live side by side for as long as needed. pom.xml and settings.gradle(.kts) do not interfere, the Maven output is in target/ and the Gradle output in build/, and neither tool reads the other’s files. Three precautions avoid surprises:

  • Ignore both output directories (target/, build/ and .gradle/) in version control.

  • Give each build its own -D and -P conventions and test both in CI: run mvn verify and ./gradlew build on the same commit, then compare the outputs as shown above. When the Gradle build is trusted, make it the build that releases, and remove the pom.xml files in a later commit.

  • Do not use mavenLocal() or publishToMavenLocal to hand artifacts between the two builds unless a downstream Maven project requires them. A stale artifact in ~/.m2 is a typical cause of "it works on my machine" in a migration.

Gradle 8.14 and Gradle 9

The 8.14.6 version of the manual chapter "Migrating Builds From Apache Maven" has the same sections and the same advice as the 9.8.1 chapter. A comparison of the two texts shows differences of wording ("build scan" against "Build Scan") and of the Kotlin DSL form of the profile example (8.14.6 reads the property with val buildProfile: String? by project; 9.8.1 uses project.findProperty("buildProfile") as String?). That statement is based on the two pages and was not run here; Gradle 8.14.x was not available. Other differences that touch a migration, from the release notes and the compatibility information that the other pages of this section collect:

Topic Gradle 8.14 (not run here) Gradle 9.8.1 (run above)

Java to run Gradle

Java 8 to 24 can run Gradle 8.14; JDK 26, used for the examples here, is outside that range.

Java 17 to 27 runs the daemon of 9.8.x, so the JDK 26 of this page works. Code can still target an older release with options.release or a toolchain.

Configuration cache

Stable since 8.1 and off by default.

"Preferred mode" since 9.0, still off by default; gradle init on a POM writes org.gradle.configuration-cache=true into gradle.properties, as shown above. Gradle 10 is announced to turn it on by default.

Kotlin DSL named<JvmTestSuite>("test") against val test by getting(JvmTestSuite::class)

Both forms work.

The delegate form is deprecated since 9.6; the form used on this page is the recommended one.

= assignment for properties (options.release = 17)

Available in the Kotlin DSL since 8.2.

Available.

Mirrors from settings.xml

Not available.

org.gradle.mirror.maven.settings since 9.8.0.

Classic mistakes

  • Trusting the converted build. The generated build compiled for Java 8, did not select the JUnit Platform, dropped the profile, the filtering, the system properties, Failsafe and Shade, and wrote api for every dependency. Treat gradle init as the way to get the module list and the dependency coordinates, and review every file it wrote.

  • Passing -Pci and expecting a profile. Nothing happens unless a script reads the property. Search the POMs for <profile> before you start and list the settings each profile changes.

  • Comparing only the build result. A green ./gradlew build says nothing about the version of commons-io that is on the classpath. Compare the dependency trees (mediation), the JAR contents and the POMs.

  • Leaving mavenLocal() as the first repository. It resolves artifacts that no repository provides and hides missing publications.

  • Reading project in a task action. The configuration cache rejects or warns about it; capture the value in a local variable (or a provider) while the task is configured.

  • Moving everything at once. Converting the build, the layout and the CI pipeline in one change makes a failure impossible to diagnose. Convert the build first; keep the layout and the artifacts the same; change them later.

  • Forgetting that the wrapper is a file of the project. gradle init writes a wrapper without a checksum. Add distributionSha256Sum and commit gradle/wrapper/gradle-wrapper.jar and the properties.

  • Expecting the same task order. A Gradle build runs what the task graph requires, in an order that is only partly defined, and runs independent tasks in parallel with the configuration cache. Declare real dependencies (dependsOn, inputs and outputs), not sequences.

Where the books are outdated

Gradle in Action (Muschko, Manning 2014) covers the Ant and Maven migration in chapter 9, for Gradle 1.7. What it describes and what replaced it:

  • The maven2gradle tool. The conversion of a POM is now the pom type of the init task of the Build Init plugin, whose implementation was inspired by maven2gradle; it also reads the effective POM, so it handles inheritance and dependencyManagement. The incubating setupBuild task of Gradle 1.x is the same init task.

  • The compile, runtime, testCompile and testRuntime configurations in the converted dependency blocks, removed in Gradle 7.0. Use implementation, api, compileOnly, runtimeOnly, testImplementation and testRuntimeOnly.

  • The maven plugin and uploadArchives for publishing and installing, removed in Gradle 7.0. Use maven-publish with publish and publishToMavenLocal.

  • Declaring dependency versions in ext properties in the root build. Use a version catalog (stable since 7.4) or a platform.

  • Injecting configuration with allprojects and subprojects from the root build. Use convention plugins in build-logic.

  • apply plugin: 'x'. Use the plugins { } block.

  • jcenter() as the repository of the examples. Removed in 9.0.0, and Bintray, which hosted JCenter, was shut down in 2021. Use mavenCentral().

Where to go next