Command Palette

Search for a command to run...

PHASE 15Advanced ~34 min· topic 4 of 6

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.

project layoutwhole filetext
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.

pom.xmlwhole filexml
<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.

terminal
$ mvn package
── expected output ──
[INFO] Scanning for projects...
[INFO]
[INFO] ----------------------< com.shop:orders-service >-----------------------
[INFO] Building orders-service 1.0.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ orders-service ---
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.14.0:compile (default-compile) @ orders-service ---
[INFO] Compiling 2 source files with javac [debug release 17] to target/classes
[INFO]
[INFO] --- compiler:3.14.0:testCompile (default-testCompile) @ orders-service ---
[INFO] Compiling 1 source file with javac [debug release 17] to target/test-classes
[INFO]
[INFO] --- surefire:3.5.3:test (default-test) @ orders-service ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO] Running com.shop.orders.PriceCalculatorTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] --- jar:3.4.1:jar (default-jar) @ orders-service ---
[INFO] Building jar: /home/dev/orders-service/target/orders-service-1.0.0-SNAPSHOT.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

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.

terminal
$ mvn dependency:tree
── expected output ──
[INFO] com.shop:orders-service:jar:1.0.0-SNAPSHOT
[INFO] +- org.slf4j:slf4j-api:jar:2.0.17:compile
[INFO] +- ch.qos.logback:logback-classic:jar:1.5.18:runtime
[INFO] | \- ch.qos.logback:logback-core:jar:1.5.18:runtime
[INFO] \- org.junit.jupiter:junit-jupiter:jar:5.12.2:test
[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:5.12.2:test
[INFO] | +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] | +- org.junit.platform:junit-platform-commons:jar:1.12.2:test
[INFO] | \- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] +- org.junit.jupiter:junit-jupiter-params:jar:5.12.2:test
[INFO] \- org.junit.jupiter:junit-jupiter-engine:jar:5.12.2:test
[INFO] \- org.junit.platform:junit-platform-engine:jar:1.12.2:test
Seeing the dependency treediagram
Rendering diagram…

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.

build.gradle.ktswhole filekotlin
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.

terminal
$ ./gradlew build --console=plain
── expected output ──
> Task :compileJava
> Task :processResources
> Task :classes
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build
 
BUILD SUCCESSFUL in 6s
5 actionable tasks: 5 executed

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. 1

    Make the newer version win

    In "nearest wins", add new Dep("commons-text", "1.12") to the orders-service list (a direct dependency, depth 1). Predict the output: now commons-text:1.12 is chosen at depth 1, and the 1.9 request from http-client is omitted. Declaring a dependency directly is the standard way to override Maven's choice.

  2. 2

    Change main code

    In the Gradle example, add a third build after inputs.put("compileJava", "main-v2");. Predict which tasks run. Everything downstream of compileJava runs again: classes, jar, compileTestJava, test and build, while processResources stays up to date.

  3. 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, then cd demo and mvn package. Read each --- plugin:goal --- line and match it to a lifecycle phase.

Code & diagrams

Maven's "nearest wins" conflict resolution Java 16+ New tab

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.

Sign in to run this example in your browser.

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}
Running a phase runs every phase before it Java 9+ New tab

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.

Sign in to run this example in your browser.

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"
Gradle-style task graph with up-to-date checks Java 9+ New tab

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.

Sign in to run this example in your browser.

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
Pinning versions with a BOMxml
<!-- 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>.

terminal
$ mvn compile
── what you'll see ──
[ERROR] COMPILATION ERROR :
[ERROR] /home/dev/orders-service/src/main/java/com/shop/orders/Checks.java:[3,29] package org.junit.jupiter.api does not exist
[ERROR] /home/dev/orders-service/src/main/java/com/shop/orders/Checks.java:[6,43] cannot find symbol
symbol: variable Assertions
location: class com.shop.orders.Checks

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.

terminal
$ java -cp target/classes com.shop.orders.App
── what you'll see ──
Exception in thread "main" java.lang.NoClassDefFoundError: org/slf4j/LoggerFactory
at com.shop.orders.App.<clinit>(App.java:8)
Caused by: java.lang.ClassNotFoundException: org.slf4j.LoggerFactory
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
... 1 more

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 NoSuchMethodError or ClassNotFoundException at runtime, not at compile time. The Maven Enforcer plugin's dependencyConvergence and requireUpperBoundDeps rules fail the build on such conflicts; Gradle offers failOnVersionConflict() 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.outputTimestamp in Maven (or isPreserveFileTimestamps = false and isReproducibleFileOrder = true on 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.xml checks 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, or gradle dependencies fed into a scanner).

Remember this

  1. 1

    Without a build tool you'd run javac with 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 runs mvn package or ./gradlew build to get the same result.

  2. 2

    Libraries are identified by coordinates: groupId:artifactId:version, for example org.slf4j:slf4j-api:2.0.17. Both tools download them from Maven Central (or a company mirror) into a local cache (~/.m2/repository for Maven, ~/.gradle/caches for Gradle) and reuse them. A version ending in -SNAPSHOT means "work in progress" and may be re-downloaded; release versions never change.

  3. 3

    Dependencies have dependencies of their own: transitive dependencies. Logback needs logback-core; JUnit needs opentest4j and 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:tree and gradle dependencies show what was chosen.

  4. 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), and provided/compileOnly (supplied by the environment, like the Servlet API or Lombok). Gradle's api versus implementation (from the java-library plugin) decides whether a dependency leaks to your consumers' compile class path.

  5. 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:compile to compile, surefire:test to test, jar:jar to package). The conventions (src/main/java, src/test/java, target/) mean a small pom.xml is enough.

  6. 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 as UP-TO-DATE when 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

01

What does a build tool do for a Java project?

02

Explain the Maven build lifecycle and the relationship between phases, plugins and goals.

03

How do Maven and Gradle resolve version conflicts between transitive dependencies?

04

What are dependency scopes or configurations, and why do they matter?

05

Why is Gradle often faster than Maven on large projects?

Practice

01

Extend the "nearest wins" program so it also prints the Gradle choice (highest version) for each artifact. Compare versions numerically, part by part.

02

Write a program that prints the Maven coordinates group:artifact:version split into their three parts, and says whether the version is a SNAPSHOT.

03

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.