Topic 15.4
Maven and Gradle Basics
In one line
Maven and Gradle are build tools: from one file (pom.xml or build.gradle.kts) they download your libraries and the libraries those need, compile your code, run the tests and package a JAR with one command. Maven uses a fixed lifecycle of phases and XML configuration; Gradle uses a task graph, a Kotlin or Groovy script, and skips work whose inputs haven't changed.
Think of it like this
Baking a cake from a recipe card. The card lists the ingredients (flour 500 g from the shop, eggs) and the steps in order (mix, bake, cool, ice). You don't grow the wheat yourself; the shop supplies it. A build tool reads your project's recipe card: it fetches the dependencies (libraries) from an online store called a repository, then runs the steps (compile, test, package) in the right order, every time, the same way on every machine.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Build tool
- A program that turns your source code into a tested, packaged application by following a build file.
- Dependency
- A library your project uses, written as group, artifact and version.
- Transitive dependency
- A library you didn't ask for directly, pulled in because one of your libraries needs it.
- Repository
- A server (like Maven Central) or local folder that stores library JARs by their coordinates.
- Scope / configuration
- A label on a dependency saying where it's needed: compiling main code, only at runtime, or only for tests.
- Lifecycle phase
- One named step in Maven's fixed build order, such as compile, test or package.
- Plugin goal
- A single job a Maven plugin performs, such as compiler:compile; goals are attached to phases.
- Task
- Gradle's unit of work, such as compileJava or test. Tasks depend on other tasks.
- Wrapper
- A small script (gradlew or mvnw) committed with the project that downloads and runs the exact tool version the project expects.
Step by step
01The standard project layout
Both tools use the same folder convention, so any Java developer can find their way around any project. Main code and tests are separated, so test code and test libraries never end up in the shipped JAR.
Build output goes to target/ (Maven) or build/ (Gradle). Neither folder belongs in version control.
orders-service/
├── pom.xml (or build.gradle.kts + settings.gradle.kts)
├── src/
│ ├── main/
│ │ ├── java/com/shop/orders/PriceCalculator.java
│ │ └── resources/logback.xml
│ └── test/
│ └── java/com/shop/orders/PriceCalculatorTest.java
└── target/ (Maven output; Gradle uses build/)02A minimal pom.xml
The POM (Project Object Model) names the project with its own coordinates, sets the Java release, lists dependencies with their scopes, and pins plugin versions. Everything else (where sources live, which plugins run in which phase) is inherited from Maven's super POM.
maven.compiler.release passes --release 17 to javac, which guarantees the code only uses Java 17 APIs even if you build with a newer JDK.
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.shop</groupId>
<artifactId>orders-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.17</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.18</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.12.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
</plugins>
</build>
</project>03mvn package: phases and plugin goals
mvn package runs every lifecycle phase up to package. Each --- plugin:version:goal --- line is a goal bound to a phase: resources copied, main code compiled, test code compiled, tests run by Surefire (which found JUnit 5 by itself), then the JAR built. If any test fails, the build stops before package.
04Seeing the dependency tree
We declared three dependencies; the tree shows nine. Each line ends with the scope. logback-core came in through logback-classic, and JUnit brought its API, engine, params and platform modules. When something odd lands on the class path, this is the first command to run.
In a conflict, Maven prints the losing version as (… - omitted for conflict with …) in verbose mode. To force a version, declare it yourself (nearest wins) or pin it in <dependencyManagement>, often by importing a BOM (bill of materials), a POM that lists matching versions for a family of libraries.
05The same project in Gradle
The java plugin adds the compileJava, test, jar and build tasks and the standard layout. A toolchain asks Gradle to compile with a specific JDK, downloading or locating it as needed. platform(...) imports JUnit's BOM so the JUnit modules share one version.
useJUnitPlatform() is required: without it, Gradle's test task looks for JUnit 4 tests. Recent Gradle versions also want the JUnit Platform launcher declared explicitly as testRuntimeOnly.
plugins {
java
}
group = "com.shop"
version = "1.0.0-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.slf4j:slf4j-api:2.0.17")
runtimeOnly("ch.qos.logback:logback-classic:1.5.18")
testImplementation(platform("org.junit:junit-bom:5.12.2"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}06Gradle's task graph and incremental builds
./gradlew build asks for the build task; Gradle works out every task it depends on and runs them in dependency order. Run it again without changes and every task is UP-TO-DATE: Gradle compared the input and output fingerprints and skipped the work. Change only a test file and only compileTestJava and test run again.
Maven has no such task-level check by default; mvn package recompiles if sources changed, but it always walks the whole lifecycle and reruns tests. That difference is why large multi-module builds are often faster on Gradle.
07A JAR is not a runnable application
mvn package produces a JAR of *your* classes only. Run it with java -cp target/classes alone and the first use of a library fails with NoClassDefFoundError: the dependencies were on the build's class path, not in the JAR.
Ways to ship: the Spring Boot plugin or the Maven Shade plugin builds a single "fat" JAR with dependencies inside; Gradle's application plugin builds a distribution with a lib/ folder and start scripts; container tools like Jib build an image directly. Under the hood all of them assemble the right class path, the thing you'd otherwise type by hand.
Try it yourself
- 1
Make the newer version win
In "nearest wins", add
new Dep("commons-text", "1.12")to theorders-servicelist (a direct dependency, depth 1). Predict the output: nowcommons-text:1.12is chosen at depth 1, and the 1.9 request fromhttp-clientis omitted. Declaring a dependency directly is the standard way to override Maven's choice. - 2
Change main code
In the Gradle example, add a third build after
inputs.put("compileJava", "main-v2");. Predict which tasks run. Everything downstream ofcompileJavaruns again:classes,jar,compileTestJava,testandbuild, whileprocessResourcesstays up to date. - 3
Run a real build
If you have Maven installed, run
mvn archetype:generate -DgroupId=com.demo -DartifactId=demo -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false, thencd demoandmvn package. Read each--- plugin:goal ---line and match it to a lifecycle phase.
Code & diagrams
commons-text 1.12 was newer but lost: both were at depth 2 and 1.9 was met first. Gradle would pick 1.12 (highest version wins). If json-lib 1.4 calls a method added in 1.12, Maven's choice fails at runtime with NoSuchMethodError.
Expected output
resolving orders-service:
+ http-client:2.1 (depth 1, via orders-service)
+ json-lib:1.4 (depth 1, via orders-service)
+ commons-text:1.9 (depth 2, via http-client)
- json-lib:1.2 (depth 2, via http-client) omitted for conflict with 1.4
- commons-text:1.12 (depth 2, via json-lib) omitted for conflict with 1.9
class path: {commons-text=1.9, http-client=2.1, json-lib=1.4}That's why "mvn install" also runs your tests: install comes after test in the lifecycle. The real default lifecycle has 23 phases; this shows the ones with goals bound for a JAR project.
Expected output
mvn compile runs 2 goals: resources:resources -> compiler:compile
mvn test runs 5 goals: resources:resources -> compiler:compile -> resources:testResources -> compiler:testCompile -> surefire:test
mvn package runs 6 goals: resources:resources -> compiler:compile -> resources:testResources -> compiler:testCompile -> surefire:test -> jar:jar
mvn install runs 7 goals: resources:resources -> compiler:compile -> resources:testResources -> compiler:testCompile -> surefire:test -> jar:jar -> install:install
mvn build: Unknown lifecycle phase "build"A depth-first walk gives a valid order (a topological sort, see the DSA course's graph modules). Real Gradle fingerprints file contents and outputs, not version strings.
Expected output
first build:
> Task :compileJava
> Task :processResources
> Task :classes
> Task :jar
> Task :compileTestJava
> Task :test
> Task :build
5 actionable tasks: 5 executed
only a test file changed:
> Task :compileJava UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :compileTestJava
> Task :test
> Task :build
5 actionable tasks: 2 executed, 3 up-to-date<!-- pom.xml: import JUnit's bill of materials, then omit versions on JUnit dependencies -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.12.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <!-- version comes from the BOM -->
<scope>test</scope>
</dependency>
</dependencies>Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use a test library in main code
In src/main/java, import org.junit.jupiter.api.Assertions while JUnit has <scope>test</scope>.
Break #2
Run the JAR without its dependencies
After mvn package, run the app with only your own classes: java -cp target/classes com.shop.orders.App.
Myth vs fact
Myth
Maven always uses the newest version of a library.
Fact
Maven picks the version nearest to your project in the dependency tree; at equal depth the first declared wins. Gradle picks the highest version by default.
Myth
Gradle and Maven produce different kinds of JARs.
Fact
Both run javac and produce standard JARs from the same layout, and both publish to the same Maven repositories. The difference is how the build is described and executed.
Myth
mvn install is needed to build a project.
Fact
install copies the artifact into your local ~/.m2 repository so other local projects can use it. To build and test, mvn package or mvn verify is enough.
Myth
The wrapper scripts are optional clutter.
Fact
gradlew and mvnw pin the build tool version, so every developer and CI agent builds with the same one. Commit them.
When it breaks
A transitive dependency is silently downgraded by Maven's nearest-wins rule.
What you see
Tests that don't touch the code path pass; in production a call fails with java.lang.NoSuchMethodError because the method exists only in the newer version that lost the conflict.
Fix & prevent
Run mvn dependency:tree -Dverbose to find the conflict, pin the version in <dependencyManagement>, and add the Enforcer plugin's dependencyConvergence rule so the build fails on future conflicts.
CI builds start failing after a new Gradle or Maven release on the build agents.
What you see
Builds that work on laptops fail in CI with plugin errors or deprecation failures, because the agents use whatever tool version is installed.
Fix & prevent
Commit and use the wrapper (./gradlew, ./mvnw) so the tool version is pinned in the repository, and pin plugin versions explicitly in the build file.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Maven's version mediation can silently downgrade a library, which surfaces as
NoSuchMethodErrororClassNotFoundExceptionat runtime, not at compile time. The Maven Enforcer plugin'sdependencyConvergenceandrequireUpperBoundDepsrules fail the build on such conflicts; Gradle offersfailOnVersionConflict()and dependency constraints. - ▸
Gradle splits configuration from execution: build scripts run in a configuration phase that builds the task graph, then tasks execute. The configuration cache stores the graph between runs, and the build cache (local or remote) restores task outputs by input hash, which makes CI builds of unchanged modules nearly free.
- ▸
Reproducible builds: set
project.build.outputTimestampin Maven (orisPreserveFileTimestamps = falseandisReproducibleFileOrder = trueon Gradle archive tasks) so the same source produces byte-identical JARs, which supply-chain tools can verify. - ▸
Dependency verification protects the supply chain: Gradle's
verification-metadata.xmlchecks checksums and signatures of every downloaded artifact, and Maven can check checksums strictly. Pair this with vulnerability scanning of the resolved tree (OWASP Dependency-Check, orgradle dependenciesfed into a scanner).
Remember this
- 1
Without a build tool you'd run
javacwith a long class path by hand, download JARs from websites, and remember which version of which library you used. A build tool makes the build declarative and reproducible: a teammate or a CI server clones the repository and runsmvn packageor./gradlew buildto get the same result. - 2
Libraries are identified by coordinates:
groupId:artifactId:version, for exampleorg.slf4j:slf4j-api:2.0.17. Both tools download them from Maven Central (or a company mirror) into a local cache (~/.m2/repositoryfor Maven,~/.gradle/cachesfor Gradle) and reuse them. A version ending in-SNAPSHOTmeans "work in progress" and may be re-downloaded; release versions never change. - 3
Dependencies have dependencies of their own: transitive dependencies. Logback needs
logback-core; JUnit needsopentest4jand the platform. When two paths ask for different versions of the same library, the tool must pick one. Maven picks the nearest to your project in the dependency tree (and, at equal depth, the first declared), even if it's older. Gradle picks the highest requested version by default.mvn dependency:treeandgradle dependenciesshow what was chosen. - 4
Dependencies also have a scope (Maven) or configuration (Gradle) saying where they're needed:
compile/implementation(main code),runtime/runtimeOnly(only when running, like a logging back-end or a JDBC driver),test/testImplementation(only for tests, like JUnit), andprovided/compileOnly(supplied by the environment, like the Servlet API or Lombok). Gradle'sapiversusimplementation(from thejava-libraryplugin) decides whether a dependency leaks to your consumers' compile class path. - 5
Maven has a fixed lifecycle:
validate,compile,test,package,verify,install,deploy(plus finer phases in between). Running a phase runs every phase before it. Plugins do the actual work: each phase has goals bound to it (compiler:compiletocompile,surefire:testtotest,jar:jartopackage). The conventions (src/main/java,src/test/java,target/) mean a smallpom.xmlis enough. - 6
Gradle models the build as a graph of tasks (
compileJava,test,jar,build) with dependencies between them, configured in a Kotlin DSL (build.gradle.kts) or Groovy. It is incremental: each task records fingerprints of its inputs and outputs and is skipped asUP-TO-DATEwhen nothing changed, and a build cache can reuse outputs across machines. Always run Gradle through the wrapper (./gradlew), which pins the Gradle version for the project; Maven has an equivalent (./mvnw).
Explain it without notes
What does a build tool do for a Java project?
Explain the Maven build lifecycle and the relationship between phases, plugins and goals.
How do Maven and Gradle resolve version conflicts between transitive dependencies?
What are dependency scopes or configurations, and why do they matter?
Why is Gradle often faster than Maven on large projects?
Practice
Extend the "nearest wins" program so it also prints the Gradle choice (highest version) for each artifact. Compare versions numerically, part by part.
Write a program that prints the Maven coordinates group:artifact:version split into their three parts, and says whether the version is a SNAPSHOT.
Write the Gradle dependencies { } block for: Jackson databind 2.17.2 for main code, the PostgreSQL JDBC driver 42.7.4 only at runtime, and Mockito 5.12.0 for tests.
Trade-offs
- ↔
Maven's fixed lifecycle and XML are verbose but predictable: every Maven project builds the same way. Gradle's scripts are concise and flexible, but custom logic can make builds hard to understand.
- ↔
Gradle's incremental builds and caches are faster on large projects; Maven is simpler to reason about and has the widest plugin ecosystem and IDE support.
- ↔
Fat JARs are easy to ship but large and can hide conflicting classes; layered container images or jlink runtimes take more setup but are smaller and faster to update.
Done when you can
Done when you can write a minimal pom.xml and build.gradle.kts with main, runtime and test dependencies.
Done when you can explain the Maven lifecycle and which goals run for mvn package.
Done when you can read mvn dependency:tree and explain nearest-wins versus highest-wins.
Done when you can explain Gradle tasks, UP-TO-DATE checks and why to use the wrapper.
Done when you know why a plain JAR fails with NoClassDefFoundError and how to package an app properly.