10. Maven and Gradle in depth
Full example: code/java-for-csharp/l10. The same three-module build exists for Maven and for Gradle, with a NuGet counterpart. check.sh runs every command below in CI on the three OSes and compares the output with the lesson.
From solutions to builds
Section titled “From solutions to builds”Lesson 1 built one project. Real code is several projects that depend on each other and on libraries that depend on other libraries. The .NET pieces all have a counterpart, but one rule differs in a way that breaks programs at run time: which version wins when two are requested.
| .NET | Maven | Gradle |
|---|---|---|
.sln listing projects |
parent POM with <modules> |
settings.gradle.kts with include(...) |
Directory.Build.props |
the <parent> POM |
a convention plugin in buildSrc |
Directory.Packages.props |
<dependencyManagement>, BOMs |
version catalog, platforms |
ProjectReference |
a dependency on the module’s coordinates | project(":lib") |
PrivateAssets="all" |
<optional>true</optional> |
compileOnly |
| no equivalent | no equivalent | implementation: hidden from consumers’ compiler |
| NU1605 downgrade error | enforcer requireUpperBoundDeps |
failOnVersionConflict() |
packages.lock.json |
no equivalent | gradle.lockfile |
global.json |
Maven Wrapper | Gradle Wrapper |
dotnet list package --include-transitive |
mvn dependency:tree |
gradle dependencies |
A three-module build
Section titled “A three-module build”The example is a small library chain. core formats book titles with Apache Commons Lang, lib builds URL slugs on top of core, and app uses lib plus Commons Text:
maven/ gradle/├── pom.xml ← parent ├── settings.gradle.kts ← like the .sln├── mvnw, mvnw.cmd ← wrapper ├── gradlew, gradlew.bat ← wrapper├── core/pom.xml ├── gradle/libs.versions.toml├── lib/pom.xml ├── buildSrc/ ← shared build logic└── app/pom.xml ├── core/build.gradle.kts ├── lib/build.gradle.kts └── app/build.gradle.ktsMaven: a parent and its modules
Section titled “Maven: a parent and its modules”A multi-module build has a parent POM with pom packaging. It lists the modules, and the modules name it as <parent> to inherit its properties and plugin versions:
<groupId>com.example.books</groupId> <artifactId>books-parent</artifactId> <version>1.0-SNAPSHOT</version> <packaging>pom</packaging>
<modules> <module>core</module> <module>lib</module> <module>app</module> </modules>
<properties> <maven.compiler.release>25</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <parent> <groupId>com.example.books</groupId> <artifactId>books-parent</artifactId> <version>1.0-SNAPSHOT</version> </parent>
<artifactId>app</artifactId>
<dependencies> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-text</artifactId> <version>1.12.0</version> </dependency> <dependency> <groupId>com.example.books</groupId> <artifactId>lib</artifactId> <version>${project.version}</version> </dependency> </dependencies>Maven’s parent plays two roles that .NET keeps apart: the .sln (which projects are built together, called the reactor) and Directory.Build.props (shared settings). A dependency on another module uses its coordinates, as if it were a published package; inside the reactor, Maven uses the module’s freshly built output instead of downloading it. mvn -pl app -am package builds app and the modules it needs (“also make”), like dotnet build App.csproj builds its project references.
Gradle: settings, a catalog and a convention plugin
Section titled “Gradle: settings, a catalog and a convention plugin”rootProject.name = "books"
dependencyResolutionManagement { repositories { mavenCentral() }}
include("core", "lib", "app")Versions live in the version catalog, gradle/libs.versions.toml, where build scripts see them as libs.commons.text:
[versions]commons-lang3 = "3.20.0"commons-text = "1.12.0"
[libraries]commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commons-lang3" }commons-text = { module = "org.apache.commons:commons-text", version.ref = "commons-text" }Shared settings go into a convention plugin, a build script in buildSrc that each project applies by name. It plays the role of Directory.Build.props, except that a project only gets the settings when it asks for them:
// Settings shared by every project that applies this plugin, like Directory.Build.propsplugins { java}
java { toolchain { languageVersion = JavaLanguageVersion.of(25) }}plugins { id("books.java-conventions") application}
dependencies { implementation(libs.commons.text) implementation(project(":lib"))}
application { mainClass = "app.Main"}My first version put these settings in a root build.gradle.kts with a subprojects { } block, as many older builds do. Gradle’s documentation now calls that cross-project configuration “an improper way to share build logic”, because a subproject’s script no longer shows everything that applies to it.
Scopes and configurations
Section titled “Scopes and configurations”A dependency is not simply “referenced”. Each tool says on which class path it goes, and whether it passes on to consumers:
| Needed for | NuGet | Maven scope | Gradle configuration |
|---|---|---|---|
| compiling and running, visible to consumers | PackageReference (default) |
compile (default) |
api (java-library plugin) |
| compiling and running, hidden from consumers’ compiler | none | none | implementation |
| compiling only (the runtime provides it) | ExcludeAssets="runtime" |
provided |
compileOnly |
| running only (a JDBC driver) | ExcludeAssets="compile" |
runtime |
runtimeOnly |
| tests | a separate test project | test |
testImplementation |
| your build only, never passed on (analyzers) | PrivateAssets="all" |
<optional>true</optional> |
compileOnly, annotationProcessor |
The line that matters is the second one. Gradle’s implementation puts a dependency on the consumer’s runtime class path but not on its compile class path. lib declares implementation(project(":core")), so app can’t use core without declaring it. A second source set in app tries:
package app;
import core.Titles;
public class Leak {
public static void main(String[] args) { System.out.println(Titles.withoutArticle("the pragmatic programmer")); }}Leak.java:3: error: package core does not existimport core.Titles; ^Leak.java:8: error: cannot find symbol System.out.println(Titles.withoutArticle("the pragmatic programmer")); ^ symbol: variable Titles location: class Leak2 errorsMaven has no such distinction: a compile dependency is on every consumer’s compile class path, transitively. NuGet behaves the same way, which is why a C# project can use a package that only a referenced project declares. app/Main.java does exactly that: it imports org.apache.commons.lang3.StringUtils without declaring Commons Lang, and it compiles. The dependency:analyze goal finds it:
[WARNING] Used undeclared dependencies found:[WARNING] org.apache.commons:commons-lang3:jar:3.20.0:compileRelying on a transitive dependency works until the library in the middle drops it.
When two versions meet
Section titled “When two versions meet”Here is the trap. core needs Commons Lang 3.20.0: it uses the Strings class, added in 3.18.0. app also depends on Commons Text 1.12.0, which depends on Commons Lang 3.14.0. Only one JAR per library can be on the class path.
Maven: the nearest definition wins
Section titled “Maven: the nearest definition wins”Maven picks the “nearest definition”: the version closest to your project in the tree, and on a tie, the first one declared. Commons Text is a direct dependency of app, so its Commons Lang sits at depth 2; core’s sits at depth 3. The older version wins:
.\mvnw package dependency:tree -Dverbose./mvnw package dependency:tree -Dverbose./mvnw package dependency:tree -Dverbosecom.example.books:app:jar:1.0-SNAPSHOT+- org.apache.commons:commons-text:jar:1.12.0:compile| \- org.apache.commons:commons-lang3:jar:3.14.0:compile\- com.example.books:lib:jar:1.0-SNAPSHOT:compile \- com.example.books:core:jar:1.0-SNAPSHOT:compile \- (org.apache.commons:commons-lang3:jar:3.20.0:compile - omitted for conflict with 3.14.0)Everything compiled, because core was compiled against its own declared version. The failure comes at run time, when core reaches for a class that 3.14.0 doesn’t have:
commons-lang3 on the class path: 3.14.0The Art Of Computer ProgrammingException in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/Strings-Dverbose is what shows the omitted version; without it, the tree only lists the winner.
Gradle: the highest version wins
Section titled “Gradle: the highest version wins”Gradle considers every requested version and selects the highest. The same graph gives 3.20.0, and the arrow shows the substitution:
.\gradlew :app:dependencies --configuration runtimeClasspath./gradlew :app:dependencies --configuration runtimeClasspath./gradlew :app:dependencies --configuration runtimeClasspathruntimeClasspath - Runtime classpath of source set 'main'.+--- org.apache.commons:commons-text:1.12.0| \--- org.apache.commons:commons-lang3:3.14.0 -> 3.20.0\--- project ':lib' \--- project ':core' \--- org.apache.commons:commons-lang3:3.20.0commons-lang3 on the class path: 3.20.0The Art Of Computer Programmingart-of-computer-programmingdependencyInsight explains a choice:
Selection reasons: - By conflict resolution: between versions 3.20.0 and 3.14.0
org.apache.commons:commons-lang3:3.20.0\--- project ':core' \--- project ':lib' \--- runtimeClasspath
org.apache.commons:commons-lang3:3.14.0 -> 3.20.0\--- org.apache.commons:commons-text:1.12.0 \--- runtimeClasspathChoosing the highest version is not always safe either: a major version can remove what an older consumer calls. But the failure mode for libraries that keep backward compatibility, like Commons Lang, is far rarer.
NuGet: lowest applicable, but never a silent downgrade
Section titled “NuGet: lowest applicable, but never a silent downgrade”NuGet has four rules: lowest applicable version, floating versions, direct dependency wins, and cousin dependencies. The nuget folder reproduces the same shape. App references Microsoft.Extensions.Logging 8.0.0, which needs Microsoft.Extensions.DependencyInjection.Abstractions at least 8.0.0, and Lib → Core references that package at 9.0.0:
Project 'App' has the following package references [net10.0]: Top-level Package Requested Resolved > Microsoft.Extensions.Logging 8.0.0 8.0.0
Transitive Package Resolved > Microsoft.Extensions.DependencyInjection 8.0.0 > Microsoft.Extensions.DependencyInjection.Abstractions 9.0.0 > Microsoft.Extensions.Logging.Abstractions 8.0.0 > Microsoft.Extensions.Options 8.0.0 > Microsoft.Extensions.Primitives 8.0.0
Microsoft.Extensions.DependencyInjection.Abstractions loaded: 9.0.0.0NuGet version numbers are minimums (>= 8.0.0), so the cousin rule takes the lowest version that satisfies every requirement, which here is the highest of the minimums, 9.0.0. Where Maven would take the nearest, NuGet lands where Gradle does.
“Direct dependency wins” can still produce Maven’s result: add a direct reference to the 8.0.0 package in App (dotnet restore -p:Downgrade=true in the example). The difference is that NuGet refuses to do it silently. NU1605 is a warning that the .NET SDK treats as an error:
error NU1605: Warning As Error: Detected package downgrade: Microsoft.Extensions.DependencyInjection.Abstractions from 9.0.0 to 8.0.0. Reference the package directly from the project to select a different version.error NU1605: App -> Lib -> Core -> Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)error NU1605: App -> Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)| Rule | Maven | Gradle | NuGet |
|---|---|---|---|
| Default choice | nearest, then first declared | highest | lowest version satisfying every minimum |
| Direct declaration | wins | wins only if it is the highest | wins |
| Downgrade below a transitive requirement | silent | not possible by default | error NU1605 |
Taking control
Section titled “Taking control”Pin the version for the whole build
Section titled “Pin the version for the whole build”<dependencyManagement> in the parent sets the version of a library wherever it appears in the tree, transitive or not, without adding it as a dependency. The example puts it in a pin profile to show both states:
<!-- mvn -Ppin: one version for the whole build, like Directory.Packages.props --> <profile> <id>pin</id> <dependencyManagement> <dependencies> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.20.0</version> </dependency> </dependencies> </dependencyManagement> </profile>com.example.books:app:jar:1.0-SNAPSHOT+- org.apache.commons:commons-text:jar:1.12.0:compile| \- org.apache.commons:commons-lang3:jar:3.20.0:compile (version managed from 3.14.0)\- com.example.books:lib:jar:1.0-SNAPSHOT:compile \- com.example.books:core:jar:1.0-SNAPSHOT:compile \- (org.apache.commons:commons-lang3:jar:3.20.0:compile - version managed from 3.20.0; omitted for duplicate)commons-lang3 on the class path: 3.20.0The Art Of Computer Programmingart-of-computer-programmingThis is Central Package Management with transitive pinning switched on. In .NET, Directory.Packages.props only pins transitive packages when CentralPackageTransitivePinningEnabled is true. In Maven, managed versions always apply to transitive dependencies.
In a real project the parent’s <dependencyManagement> is not in a profile. Profiles are Maven’s conditional blocks, the counterpart of an MSBuild Condition, and they serve here only to keep both states in one example.
A BOM (bill of materials) is a POM that contains only <dependencyManagement>, published so that other builds can import it. This course’s own pom.xml imports JUnit’s BOM, which is why its JUnit dependency has no version:
<dependencyManagement> <dependencies> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>${junit.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>The import scope exists only inside <dependencyManagement>: it copies the BOM’s managed versions into this POM. Spring Boot’s spring-boot-starter-parent, in the WSL course’s example, goes further: it is a parent whose own parent is Spring Boot’s BOM, spring-boot-dependencies, and it also configures plugins. Gradle imports the same BOMs with testImplementation(platform("org.junit:junit-bom:6.1.3")). NuGet has no BOM concept: a Directory.Packages.props stays in the repository that uses it.
Fail the build instead
Section titled “Fail the build instead”Pinning fixes a conflict you know about. The enforcer’s requireUpperBoundDeps rule finds the ones you don’t: it fails when a dependency resolves to a lower version than something in the tree asked for. It is the closest thing to NU1605:
[ERROR] Require upper bound dependencies error for org.apache.commons:commons-lang3:3.14.0. Paths to dependency are:[ERROR] +-com.example.books:app:1.0-SNAPSHOT[ERROR] +-org.apache.commons:commons-text:1.12.0[ERROR] +-org.apache.commons:commons-lang3:3.14.0[ERROR] and[ERROR] +-com.example.books:app:1.0-SNAPSHOT[ERROR] +-com.example.books:lib:1.0-SNAPSHOT[ERROR] +-com.example.books:core:1.0-SNAPSHOT[ERROR] +-org.apache.commons:commons-lang3:3.20.0[ERROR] ]With the pin profile as well (-Ppin,enforce), the rule passes.
Wrappers and lock files
Section titled “Wrappers and lock files”The wrapper fixes the build tool’s version, as global.json fixes the SDK’s. mvn wrapper:wrapper generates mvnw, mvnw.cmd and a properties file, all to commit:
wrapperVersion=3.3.4distributionType=only-scriptdistributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zipgradle wrapper generates gradlew, gradlew.bat and gradle/wrapper/gradle-wrapper.jar next to the properties file. The journal describes a repository where only the properties file was committed, which leaves the wrapper unusable.
A lock file records the exact versions a resolution produced, so that the next build gets the same ones. NuGet writes packages.lock.json when RestorePackagesWithLockFile is true; Gradle writes a gradle.lockfile per project when dependencyLocking { lockAllConfigurations() } is set and a build runs with --write-locks. Maven has none: its resolution is deterministic for a given POM, unless the POM uses version ranges or -SNAPSHOT dependencies, which is one more reason to avoid both in releases.
Key takeaways
Section titled “Key takeaways”- A Maven parent POM is both the
.slnandDirectory.Build.props; Gradle splits them intosettings.gradle.ktsand convention plugins. - Gradle’s
implementationhides a dependency from consumers’ compilers; Maven and NuGet let transitive dependencies leak into compilation.mvn dependency:analyzefinds the leaks. - On a version conflict, Maven takes the nearest version, Gradle the highest, NuGet the lowest that satisfies every minimum and errors on a downgrade. Maven’s rule can silently put an older library on the class path, and the program fails only at run time.
mvn dependency:tree -Dverbose,gradle dependenciesanddependencyInsightshow what won and why.- Pin versions with
<dependencyManagement>and BOMs, or a version catalog and platforms; make conflicts fail the build with the enforcer orfailOnVersionConflict(). - Commit the wrapper. Gradle and NuGet can lock resolved versions; Maven relies on exact versions instead.
Exercises
Section titled “Exercises”- Make the Maven build work without the
pinprofile and without touchingcoreorlib.
Solution
Declare Commons Lang in app itself. A direct dependency is at depth 1, the nearest possible. The example keeps this solution in a direct profile of app/pom.xml:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.20.0</version> </dependency>com.example.books:app:jar:1.0-SNAPSHOT+- org.apache.commons:commons-text:jar:1.12.0:compile| \- (org.apache.commons:commons-lang3:jar:3.14.0:compile - omitted for conflict with 3.20.0)+- com.example.books:lib:jar:1.0-SNAPSHOT:compile| \- com.example.books:core:jar:1.0-SNAPSHOT:compile| \- (org.apache.commons:commons-lang3:jar:3.20.0:compile - omitted for duplicate)\- org.apache.commons:commons-lang3:jar:3.20.0:compilecommons-lang3 on the class path: 3.20.0The Art Of Computer Programmingart-of-computer-programmingIt works, and it also fixes the undeclared-dependency warning, since app uses StringUtils. The drawback: every application that uses lib must know about core’s requirement and repeat it. A version managed in a shared parent or BOM scales better.
- Make the Gradle build fail on this conflict, as the enforcer does, instead of silently choosing the highest version.
Solution
// Exercise 2: ./gradlew -PfailOnConflict fails instead of choosing the highest versionif (providers.gradleProperty("failOnConflict").isPresent) { configurations.all { resolutionStrategy.failOnVersionConflict() }}* What went wrong:Execution failed for task ':app:run' (registered by plugin 'org.gradle.application').> Could not resolve all files for configuration ':app:runtimeClasspath'. > Could not resolve org.apache.commons:commons-lang3:3.14.0. Required by: project ':app' > org.apache.commons:commons-text:1.12.0 > Conflict found for module 'org.apache.commons:commons-lang3': between versions 3.20.0 and 3.14.0> There is 1 more failure with an identical cause.failOnVersionConflict() applies to each configuration it is set on. compileJava still succeeds, because app’s compile class path contains only 3.14.0: core and its Commons Lang are implementation dependencies of lib, so the conflict exists only on the runtime class path. That is the practical difference from the enforcer, which checks the whole tree.
- Reproduce Maven’s failure in Gradle: force Commons Lang 3.14.0 from
app. What does the tree show, and what would NuGet do with the same request?
Solution
// Exercise 3: ./gradlew -PstrictLang3 forces the old version, as Maven's nearest-wins rule didif (providers.gradleProperty("strictLang3").isPresent) { dependencies { implementation(libs.commons.lang3) { version { strictly("3.14.0") } } }}runtimeClasspath - Runtime classpath of source set 'main'.+--- org.apache.commons:commons-text:1.12.0| \--- org.apache.commons:commons-lang3:3.14.0+--- project ':lib'| \--- project ':core'| \--- org.apache.commons:commons-lang3:3.20.0 -> 3.14.0\--- org.apache.commons:commons-lang3:{strictly 3.14.0} -> 3.14.0commons-lang3 on the class path: 3.14.0The Art Of Computer ProgrammingException in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringsA plain implementation("…:3.14.0") would lose to 3.20.0: in Gradle a direct declaration is one more candidate, not an override. strictly is how you downgrade, and the tree marks it {strictly 3.14.0} so a reader sees it was deliberate. NuGet, given a direct 8.0.0 reference below a transitive 9.0.0 requirement, stops with NU1605. Of the three tools, only Maven makes the downgrade without being told to and without saying so.
Sources
Section titled “Sources”- Maven: introduction to the dependency mechanism, multi-module builds, profiles,
requireUpperBoundDeps, Maven Wrapper - Gradle: graph resolution, the Java Library plugin, multi-project builds, sharing build logic, dependency locking
- NuGet: dependency resolution, Central Package Management, PackageReference in project files, NU1605