Maven and Gradle
Lifecycles, dependency scopes, transitive resolution, BOMs, plugins, and the reproducible build that produces the same jar twice.
You can compile Java with javac and you have already done it. The moment a project needs a third-party library, that stops scaling: you have to find the jar, download it, put it somewhere, name it on the classpath, and then discover that it needs four other jars you have never heard of.
A build tool exists to answer that, and two others beside it: what does this project depend on, what steps turn source into an artifact, and will it produce the same thing on a colleague's machine. Maven and Gradle answer all three; they disagree mainly about how much you should have to say.
Coordinates name every jar in the world
Three strings identify any artifact in the public repository, and they are called GAV:
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>Group is usually a reversed domain, artifact is the library, version is the release. Together they map onto a path in a repository — Maven Central for most things — and onto a file in your local cache under ~/.m2/repository. Gradle uses the same coordinates with a shorter spelling, org.apache.httpcomponents:httpclient:4.5.14, because it is the same ecosystem underneath.
This is worth saying plainly: Maven and Gradle are not competing package ecosystems. They are two front ends over the same artifacts.
The POM declares; the lifecycle runs
A Maven build is an XML file that says what the project is. Almost nothing in it is a step to perform:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId>
<artifactId>demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
</dependencies>
</project>The steps come from the lifecycle, a fixed ordered list of phases that every Maven project shares: validate, compile, test, package, verify, install, deploy. You name a phase and Maven runs it and everything before it.
Watch what one command actually does:
$ mvn package
[INFO] --- resources:3.4.0:resources (default-resources) @ demo ---
[INFO] --- compiler:3.15.0:compile (default-compile) @ demo ---
[INFO] --- resources:3.4.0:testResources (default-testResources) @ demo ---
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ demo ---
[INFO] --- surefire:3.5.4:test (default-test) @ demo ---
[INFO] --- jar:3.5.0:jar (default-jar) @ demo ---
[INFO] BUILD SUCCESSSix plugin executions from one word, in a fixed order, and none of them named in the POM. That is the Maven bargain: convention over configuration. Source in src/main/java, tests in src/test/java, output in target/, and the build works without your describing it. Fight the convention and the tool fights back; accept it and there is remarkably little to write.
Two consequences follow immediately, and both come up in interviews. You cannot package without tests running, because test is an earlier phase — which is what -DskipTests is for and why using it habitually is a smell. And mvn clean install is two things: the clean lifecycle, then install, which puts your jar into the local ~/.m2 cache so another project on the same machine can depend on it.
Scopes decide when a dependency exists
A dependency is not simply present. It is present at particular times, and the scope says which:
| Scope | On the compile classpath | At test time | Inside the artifact | Typical use |
|---|---|---|---|---|
compile (default) | yes | yes | yes | the libraries you call |
provided | yes | yes | no | the servlet API, something the container supplies |
runtime | no | yes | yes | a JDBC driver you never import |
test | no | yes | no | JUnit, Mockito, Testcontainers |
Two of these prevent real bugs. runtime for a database driver is right because your code should be talking to java.sql, not to the driver's classes — if your build breaks when you set it to runtime, you have a direct import you did not intend. And provided is how you avoid shipping a second copy of a library the environment already has, which at best bloats the artifact and at worst produces two versions of the same class.
Transitivity, and the rule that surprises everyone
Dependencies pull their own dependencies. That is the feature — but it means you end up with jars you never asked for, at versions you never chose:
$ mvn dependency:tree
[INFO] demo:demo:jar:1.0.0
[INFO] +- org.apache.httpcomponents:httpclient:jar:4.5.14:compile
[INFO] | +- org.apache.httpcomponents:httpcore:jar:4.4.16:compile
[INFO] | +- commons-logging:commons-logging:jar:1.2:compile
[INFO] | \- commons-codec:commons-codec:jar:1.11:compileOne declared dependency, four jars. Now the part worth memorising. Suppose you also declare commons-codec yourself, at an older version than the one httpclient wants:
$ mvn dependency:tree -Dverbose
[INFO] demo:demo:jar:1.0.0
[INFO] +- org.apache.httpcomponents:httpclient:jar:4.5.14:compile
[INFO] | +- org.apache.httpcomponents:httpcore:jar:4.4.16:compile
[INFO] | +- commons-logging:commons-logging:jar:1.2:compile
[INFO] | \- (commons-codec:commons-codec:jar:1.11 - omitted for conflict with 1.9)
[INFO] +- commons-codec:commons-codec:jar:1.9:compileRead the omission line. Maven resolved the conflict in favour of 1.9 — the older version — because Maven's rule is nearest wins: the declaration closest to your project in the dependency tree, not the highest version. Yours is at depth one; httpclient's is at depth two.
So httpclient is now running against a version of commons-codec older than the one it was compiled against. If it calls a method added in 1.10 or 1.11, nothing fails at compile time — your code compiles fine, because your code is not the caller. It fails in production, on the path that reaches that method, as a NoSuchMethodError.
Gradle's default is the opposite: it picks the highest version among the requested ones. Neither rule is right in general. What matters is knowing which one your tool follows, because the same set of dependencies can produce two different classpaths under the two tools.
Where you need certainty, say so explicitly. <dependencyManagement> pins a version wherever it appears in the tree, and <exclusions> remove a transitive you do not want.
A BOM is a list of versions nobody repeats
Once a project uses twelve Spring libraries that must all be the same release, repeating the version twelve times is a bug waiting for the day somebody updates eleven of them. A BOM — bill of materials — is a POM that contains nothing but managed versions, imported once:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.2.5</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>After that, dependencies are declared without a version and the BOM supplies it. One line changes the whole set, and the set is one the maintainers actually tested together. This is most of what Spring Boot's parent POM is doing for you, and it is why overriding a single Spring version by hand is usually how a working project stops working.
Multi-module builds
A larger codebase splits into modules with a parent that lists them:
<packaging>pom</packaging>
<modules>
<module>orders-domain</module>
<module>orders-api</module>
<module>orders-worker</module>
</modules>Maven works out the order from the dependencies between them, builds each once, and a module depends on its sibling by coordinates like any other library. The parent holds shared configuration — the Java release, plugin versions, the dependencyManagement block — so the children stay short.
The value is not tidiness; it is enforced direction. If orders-domain does not depend on orders-api, then no class in the domain can import a controller, and the build fails the moment somebody tries. A package convention asks people to be disciplined. A module boundary makes the compiler enforce it.
Gradle: the same problem, a different model
Gradle keeps the coordinates and the repositories and changes the middle. A build script is code — Kotlin or Groovy — and the build is a directed graph of tasks rather than a fixed lifecycle:
plugins {
java
id("org.springframework.boot") version "3.2.5"
}
repositories { mavenCentral() }
dependencies {
implementation("org.apache.httpcomponents:httpclient:4.5.14")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.junit.jupiter:junit-jupiter")
}Three differences are worth carrying:
- Tasks, not phases. Each task declares its inputs and outputs, so Gradle can skip any task whose inputs have not changed, run independent tasks in parallel, and reuse results from a build cache. On a large project that is the headline difference, and it is why builds that take minutes under Maven can take seconds under Gradle.
- Configurations, not scopes.
implementationversusapiis the one that matters:apiputs a dependency on the compile classpath of everyone who depends on you,implementationdoes not. A library that usesimplementationcan change its internals without breaking its consumers' compilation — Maven'scompilescope has no equivalent distinction, and that leakage is exactly what it costs. - Plugins apply tasks.
plugins { java }is what createscompileJava,test,jar. The script is code, so anything is expressible — which is the strength and the cost. A Maven POM that nobody can read is rare; a Gradle script that nobody can read is a genre.
Choosing between them is less dramatic than the internet suggests. Maven for a conventional service where the build is not the interesting part; Gradle where build time hurts, or the build genuinely needs logic. Most Spring Boot services are fine either way, and neither choice is hard to reverse in the first week or hard to justify in the tenth year.
Reproducible builds
"It works on my machine" is often a build problem before it is a code problem. Three habits remove most of it:
- Never use a version range or a snapshot in a release.
[4.0,5.0)and1.0-SNAPSHOTresolve to different jars on different days, which is the definition of unreproducible. - Commit the wrapper.
./mvnwand./gradlewpin the tool's own version and download it on demand, so a colleague with a different Maven installed still runs yours. Use the wrapper in CI too — that is the point of it. - Pin plugin versions. An unpinned plugin is a dependency you forgot you had, and it changes under you.
Do those and the same commit produces the same classpath in a year. Skip them and you will eventually spend a day on a bug that exists only in CI.