Maven & Gradle
Reviewed & published by Brayan K
No serious Java project is built by hand. By the end of this lesson you'll write a pom.xml and a build.gradle, add pinned dependencies, run the Maven lifecycle, and know exactly when to reach for Maven versus Gradle.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should be comfortable with Unit Testing (build tools run your tests) and basic OOP project structure. A little XML familiarity helps for Maven, but it is not required.
What You'll Learn
- You'll be able to write a pom.xml with GAV coordinates and dependencies
- You'll be able to run the Maven lifecycle: validate, compile, test, package, install
- You'll be able to write a build.gradle with dependencies and tasks
- You'll be able to add dependencies and pin exact versions safely
- You'll be able to read a transitive dependency tree and fix version conflicts
- You'll be able to choose Maven or Gradle for a given project
🧱 The Big Idea — A Build Tool Is Your Project's Recipe
A build tool turns your source code into a runnable, shippable artifact. It fetches the libraries you need, compiles your code, runs your tests, and packages the result into a JAR — every time, the same way, on every machine.
💡 Analogy: A build tool is like a recipe with an automatic grocery service. The recipe (your pom.xml or build.gradle) lists the ingredients (dependencies) and the steps (compile, test, package). The tool does the shopping for you — it downloads each ingredient and the ingredients those ingredients need (transitive dependencies) — then cooks the dish in the right order. Maven hands you a fixed recipe template; Gradle lets you write your own.
Before build tools, developers downloaded JARs by hand and fought "it works on my machine" bugs. Maven (2004) and Gradle (2012) ended that: declare what you need, and the tool produces an identical build for everyone.
1️⃣ Maven — Create, Build, and the Lifecycle
Maven is built on convention over configuration: put your code in src/main/java and your tests in src/test/java, and Maven just knows what to do. You drive it through lifecycle phases that always run in order.
| Phase | Command | What it does |
|---|---|---|
| validate | mvn validate | Check the project is correct |
| compile | mvn compile | Compile source to target/classes |
| test | mvn test | Run the unit tests |
| package | mvn package | Bundle into a JAR/WAR |
| install | mvn install | Copy the JAR to your local ~/.m2 repo |
The key rule: each phase runs every phase before it. So mvn package automatically validates, compiles, and tests first. Read the worked output below — you can see all four phases run from one command.
# Generate a new project with the standard directory layout.
mvn archetype:generate \
-DgroupId=com.mycompany \
-DartifactId=my-app \
-DarchetypeArtifactId=maven-archetype-quickstart \
-DinteractiveMode=false
cd my-app
# The layout Maven creates AND expects (convention over configuration):
# src/main/java/ your application source code
# src/main/resources/ config files (application.properties)
# src/test/java/ your test code
# target/ build output (compiled classes + the JAR)
# pom.xml the Project Object Model — deps + build config
# Run the build. 'package' runs every phase BEFORE it, in order:
# validate -> compile -> test -> package
mvn package2️⃣ The pom.xml — Coordinates, Scopes, and Plugins
The pom.xml (Project Object Model) is Maven's recipe. Three things make it work:
- GAV coordinates — groupId + artifactId + version name your artifact, e.g. com.mycompany:my-app:1.0.0.
- Dependencies — each one is another GAV. A scope controls where it appears: compile (default, everywhere) or test (test classpath only).
- Plugins — these add real work to the lifecycle. The shade plugin, for example, builds a "fat JAR" that bundles your dependencies inside.
🎯 Your Turn #1 — Finish the pom.xml
Fill in the artifactId, version, and the dependency scope. The expected result is in the comments.
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- 🎯 YOUR TURN — fill in the blanks marked with ___ -->
<!-- 1) Give this artifact its GAV coordinates. -->
<groupId>com.example</groupId>
<artifactId>___</artifactId> <!-- 👉 name it "todo-app" -->
<version>___</version> <!-- 👉 use "1.0.0" -->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>___</scope> <!-- 👉 tests only: use "test" -->
</dependency>
</dependencies>
</project>
<!-- ✅ Expected: 'mvn validate' prints BUILD SUCCESS with -->
<!-- artifactId = todo-app, version = 1.0.0, JUnit on the TEST classpath. -->3️⃣ Gradle — The Same Job, Written in Code
Gradle does everything Maven does, but the recipe is a real script (build.gradle in Groovy, or build.gradle.kts in Kotlin) instead of XML. The payoff is brevity and speed.
plugins { ... } — turn on capabilities (the java plugin adds compile/test/jar tasks).
repositories { mavenCentral() } — where dependencies are downloaded from.
implementation '...' — a compile dependency, written on one line (Maven's compile scope).
testImplementation '...' — a test-only dependency (Maven's test scope).
tasks — the unit of work Gradle runs (build, test, run, or your own).
A single implementation line replaces a seven-line XML block. Gradle is also typically 2-10x faster on repeat builds because it caches task outputs and keeps a daemon warm.
// build.gradle (Groovy DSL) — code, not XML. Far less ceremony.
plugins {
id 'java' // adds compile, test, jar tasks
id 'application' // adds the 'run' task
}
group = 'com.mycompany'
version = '1.0.0'
repositories {
mavenCentral() // where dependencies are downloaded from
}
dependencies {
// implementation = compile scope. ONE line vs 7 of XML.
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0'
// testImplementation = test scope (only on the test classpath).
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
}
application {
mainClass = 'com.mycompany.App' // entry point for './gradlew run'
}
tasks.named('test') {
useJUnitPlatform() // run tests with the JUnit 5 engine
}# Always use the wrapper (./gradlew) committed in the repo, NOT a global
# 'gradle' install — the wrapper pins the exact Gradle version for everyone.
./gradlew build # compile + test + package (= mvn package)
./gradlew test # run the tests (= mvn test)
./gradlew run # run the application (= mvn exec:java)
./gradlew clean # delete build output (= mvn clean)
./gradlew dependencies # print the resolved graph (= mvn dependency:tree)🎯 Your Turn #2 — Finish the build.gradle
Add the repository and the two dependency configurations. The expected result is in the comments.
// build.gradle
// 🎯 YOUR TURN — fill in the blanks marked with ___
plugins {
id 'java'
}
repositories {
___() // 👉 the standard public repo: mavenCentral
}
dependencies {
// A normal compile-time library:
___ 'org.apache.commons:commons-lang3:3.14.0' // 👉 use: implementation
// A test-only library:
___ 'org.junit.jupiter:junit-jupiter:5.10.2' // 👉 use: testImplementation
}
// ✅ Expected: './gradlew build' downloads both jars from Maven Central and
// prints BUILD SUCCESSFUL. commons-lang3 is on the main classpath;
// junit-jupiter is on the test classpath only.4️⃣ Dependency Management & Transitive Dependencies
You rarely depend on just one library. When you add jackson-databind, it needs jackson-core and jackson-annotations — so the build downloads those too. Those are transitive dependencies: dependencies of your dependencies.
Sometimes two libraries pull in different versions of the same transitive dependency — a version conflict. Maven resolves it by "nearest wins" (the version closest to your project in the tree). To see and debug the full graph, use mvn dependency:tree or ./gradlew dependencies, then pin the version you want explicitly to force a single, predictable answer.
Mini-Challenge — Add a Library Yourself
No blanks this time — just the brief. Write the dependency lines from scratch, then verify with the dependency report.
// 🎯 MINI-CHALLENGE: add a JSON library to a Gradle build
//
// Starting from a build.gradle that already has the 'java' plugin and
// mavenCentral():
// 1. Add Google's Gson as a COMPILE dependency.
// coordinates -> com.google.code.gson:gson:2.11.0
// 2. Add JUnit 5 as a TEST-ONLY dependency.
// coordinates -> org.junit.jupiter:junit-jupiter:5.10.2
// 3. Run './gradlew dependencies' to confirm Gson resolved.
//
// ✅ Expected: BUILD SUCCESSFUL, and the dependency report lists
// com.google.code.gson:gson:2.11.0 on the compileClasspath.
// your build.gradle dependencies { } block hereCommon Errors
- ❌ Version conflict (NoSuchMethodError at runtime): two libraries dragged in different versions of the same transitive dependency, so the wrong one won. Diagnose with mvn dependency:tree, then pin the version you want in your own <dependencies> / dependencies {} block to override it.
- ❌ Shipping against a -SNAPSHOT: a SNAPSHOT can change under you, so a build that worked yesterday breaks today. Depend on SNAPSHOTs only during active development; for anything you release, pin a real release version.
- ❌ Not pinning versions (LATEST / open ranges): <version>LATEST</version> or 1.+ means "whatever is newest", so a new release silently breaks your build. Always pin an exact version like 2.17.0.
- ❌ Plugin misconfiguration (No plugin found for prefix 'X' or skipped phase): a plugin in the wrong place or with a missing version won't bind to the lifecycle. Put plugins inside <build><plugins> (Maven) or the plugins {} block (Gradle), and give each one a version.
- ❌ Could not resolve dependency: a typo in the GAV coordinates or a missing mavenCentral() repository. Double-check the exact coordinates on search.maven.org and confirm a repository is declared.
Pro Tips
- 💡 Generate new projects from start.spring.io or mvn archetype:generate — never hand-write the whole file.
- 💡 Always commit the wrapper (mvnw / gradlew) so everyone builds with the same tool version.
- 💡 Use a BOM (Bill of Materials) to manage versions of related libraries in one place, so they never drift apart.
- 💡 Add target/, build/, and .idea/ to .gitignore — build output is regenerated, never committed.
📋 Quick Reference — Maven vs Gradle
| Task | Maven | Gradle |
|---|---|---|
| Config file | pom.xml (XML) | build.gradle (code) |
| Build (compile+test+jar) | mvn package | ./gradlew build |
| Run tests | mvn test | ./gradlew test |
| Install to local repo | mvn install | ./gradlew publishToMavenLocal |
| Clean output | mvn clean | ./gradlew clean |
| Compile dependency | <scope>compile</scope> | implementation '...' |
| Test dependency | <scope>test</scope> | testImplementation '...' |
| Dependency tree | mvn dependency:tree | ./gradlew dependencies |
🎉 Lesson Complete!
You can now write a pom.xml with GAV coordinates and scoped dependencies, run the Maven lifecycle (validate → compile → test → package → install), write a build.gradle with dependencies and tasks, read a transitive dependency tree, fix version conflicts, and choose Maven or Gradle for the job. That is the build-tool literacy every Java team expects.
Practice quiz
What are the three GAV coordinates that uniquely identify an artifact?
- name, type, scope
- repo, branch, tag
- group, artifact, version
- id, key, value
Answer: group, artifact, version. groupId (who), artifactId (what), and version (which release) form coordinates like com.mycompany:my-app:1.0.0.
What is the main difference between Maven and Gradle config files?
- Maven uses XML (pom.xml), Gradle uses a script (build.gradle)
- Maven uses code, Gradle uses XML
- Both use YAML
- Both use JSON
Answer: Maven uses XML (pom.xml), Gradle uses a script (build.gradle). Maven configures in XML (pom.xml) with strict conventions; Gradle uses a Groovy/Kotlin script, more concise and flexible.
Because each Maven phase runs every phase before it, what does 'mvn package' do first?
- Nothing else
- Only compile
- install and deploy
- validate, compile, then test
Answer: validate, compile, then test. Lifecycle phases run in order, so package automatically validates, compiles, and tests before packaging.
What is a transitive dependency?
- A dependency you list twice
- A dependency of your dependency that you never listed yourself
- A test-only dependency
- A dependency on the JDK
Answer: A dependency of your dependency that you never listed yourself. jackson-databind pulls in jackson-core and jackson-annotations automatically — those are transitive dependencies.
Which Maven scope puts a dependency only on the test classpath?
- test
- compile
- runtime
- provided
Answer: test. The test scope keeps a dependency (like JUnit) off the main classpath and out of the shipped JAR.
What is Gradle's equivalent of Maven's compile scope?
- testImplementation
- runtimeOnly
- implementation
- compileOnly
Answer: implementation. implementation '...' is a one-line compile dependency, replacing Maven's seven-line XML block.
Why should you always commit and use the wrapper (./gradlew or mvnw)?
- It builds faster
- It pins the exact build-tool version so everyone builds identically
- It is required by Java 21
- It skips tests
Answer: It pins the exact build-tool version so everyone builds identically. The wrapper pins the tool version for the whole team, avoiding 'works on my machine' build differences.
What does a -SNAPSHOT version mean?
- An immutable release
- A backup copy
- A security patch
- An in-progress build that can change at any time and is re-downloaded
Answer: An in-progress build that can change at any time and is re-downloaded. A -SNAPSHOT is a mutable, in-progress build; a release version like 1.2.0 is immutable for reproducible builds.
Why should you pin an exact dependency version like 2.17.0?
- It downloads faster
- So today's build is identical to next year's — LATEST or ranges can silently break it
- It uses less disk
- Maven requires it
Answer: So today's build is identical to next year's — LATEST or ranges can silently break it. Pinning an exact version makes builds reproducible; LATEST or open ranges let a new release break your build.
Which command prints the resolved transitive dependency tree in Gradle?
- ./gradlew build
- ./gradlew clean
- ./gradlew dependencies
- ./gradlew run
Answer: ./gradlew dependencies. ./gradlew dependencies prints the resolved graph (Maven's equivalent is mvn dependency:tree).
Continue this course
- Previous: Unit Testing in Java with JUnit & Mockito
- Next: Modular Java (JPMS) Architecture Essentials — Structure large Java projects with the Java Platform Module System
- Quick reference: Java cheat sheet
Frequently asked questions
What is the difference between Maven and Gradle?
Both are build tools that compile, test, package, and manage dependencies for Java projects. Maven configures everything in an XML file (pom.xml) and favours strict conventions, so projects look the same everywhere. Gradle configures the build in real code (a Groovy or Kotlin script), which is more concise and flexible, and it is usually 2-10x faster on repeat builds thanks to incremental compilation, a build cache, and a long-running daemon.
What are groupId, artifactId, and version (GAV coordinates)?
They are the three values that uniquely identify any artifact in a repository like Maven Central. groupId is your organisation's reverse-domain namespace (com.mycompany), artifactId is the project name (my-app), and version is the release (1.0.0). Together they form a coordinate such as com.mycompany:my-app:1.0.0 that any other build can depend on.
What is a transitive dependency?
It is a dependency of your dependency that you never listed yourself. If you depend on jackson-databind, it pulls in jackson-core and jackson-annotations automatically, and those come in too. Both Maven and Gradle resolve this whole graph for you. Run 'mvn dependency:tree' or './gradlew dependencies' to see every transitive jar your build is actually using.
What does a SNAPSHOT version mean, and how is it different from a release?
A version ending in -SNAPSHOT (e.g. 1.2.0-SNAPSHOT) is an in-progress build that can change at any time, so the tool re-downloads the latest copy. A release version (1.2.0) is immutable: once published it never changes, so builds are reproducible. Depend on SNAPSHOTs only inside your own active development; never ship a release that depends on someone else's SNAPSHOT.
Should I choose Maven or Gradle for a new project?
Pick Maven when you want simplicity, stability, and a setup any Java developer instantly recognises — it is the enterprise default and great for straightforward libraries and services. Pick Gradle when build speed matters, when you have many modules, or when you need custom build logic — it is the standard for Android and a popular choice for new Spring Boot apps. Either way, generate the project from start.spring.io or 'mvn archetype:generate' rather than writing it by hand.