Build Tools in Java: Maven and Gradle
Baking one cake at home is easy: you remember the recipe, buy the flour and switch on the oven. A bakery that makes five hundred cakes a day can't work from memory. It has a written recipe card, a supplier who delivers the same flour every morning, and a fixed order of steps that every baker follows. A build tool is that bakery system for Java. With one file in the project, it knows which libraries to download, how to compile the code, when to run the tests and how to package the result, and every developer on the team gets the same result with one command. In this lesson, you will learn the standard project layout, build a small project with Maven and then with Gradle, and see how the two compare.
What Is a Build Tool?
A build tool in Java is a program that is used to automate building a project: downloading the libraries it depends on, compiling the source code, running the tests, and packaging the classes into a JAR. You describe the project once in a build file, and the tool does the rest.
| Without a build tool | With Maven or Gradle |
|---|---|
| Download every library JAR by hand, and the JARs those need | Write one line per library; the tool downloads it and everything it needs |
Long javac -cp ... and java -cp ... commands | mvn package or ./gradlew build |
| Tests run only if someone remembers | Tests run on every build, and a failing test stops it |
| Each developer's setup is a little different | The build file is committed, so everyone builds the same way |
Maven (2004) describes a project in an XML file called pom.xml. Gradle (2012) uses a script, build.gradle.kts in Kotlin or build.gradle in Groovy. Both are used widely: Maven is common in enterprise projects, and Gradle is the standard for Android and popular for large builds.
The Standard Project Layout
Both tools expect the same folder structure, so a Java developer can open any project and find things immediately.
| Folder | What goes in it |
|---|---|
src/main/java | Application source code, in package folders |
src/main/resources | Files copied into the JAR as they are, such as configuration |
src/test/java | Test classes, never packaged into the JAR |
target/ (Maven) or build/ (Gradle) | Everything the tool generates; safe to delete, never committed |
Maven
Let's build a small project, fee-app, that calculates school fees. It has three classes in src/main/java/school/fees, a test class in src/test/java/school/fees, and it uses Google's Gson library to turn an object into JSON.
package school.fees;
import com.google.gson.Gson;
public class App {
public static void main(String[] args) {
FeeCalculator calc = new FeeCalculator(500);
Student s = new Student("Navika", 15000, 3);
System.out.println(new Gson().toJson(s));
System.out.println("Total: "
+ calc.totalFee(s.baseFee(), s.lateDays()));
}
}The pom.xml File
The POM (Project Object Model) describes the project for Maven:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.school</groupId>
<artifactId>fee-app</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.15.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.1</version>
</plugin>
</plugins>
</build>
</project>Explanation:
groupId,artifactIdandversionare the project's coordinates, its unique name.groupIdis usually a reversed domain, like a package name.maven.compiler.releasecompiles for Java 25, the same asjavac --release 25.- Each
<dependency>names a library by its coordinates. Maven downloads it from Maven Central, the public library repository, and stores it in a local cache (~/.m2/repository), so it is downloaded only once. <scope>test</scope>makes JUnit available to the tests only. It is not part of the application.- The Surefire plugin runs the tests. Pinning plugin versions keeps the build the same on every machine.
Building with Maven
Maven builds in a fixed order of phases called the lifecycle: validate, compile, test, package, verify, install and deploy. When you ask for a phase, Maven runs every phase before it first.
mvn clean package[INFO] Scanning for projects...
[INFO]
[INFO] ---------------------< com.example.school:fee-app >---------------------
[INFO] Building fee-app 1.0.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.5.0:clean (default-clean) @ fee-app ---
[INFO] Deleting ~/fee-app/target
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ fee-app ---
[INFO] Copying 0 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.16.0:compile (default-compile) @ fee-app ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 3 source files with javac [debug release 25] to target/classes
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ fee-app ---
[INFO] skip non existing resourceDirectory ~/fee-app/src/test/resources
[INFO]
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ fee-app ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 1 source file with javac [debug release 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.6.1:test (default-test) @ fee-app ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running school.fees.FeeCalculatorTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.017 s -- in school.fees.FeeCalculatorTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO]
[INFO] --- jar:3.5.1:jar (default-jar) @ fee-app ---
[INFO] Building jar: ~/fee-app/target/fee-app-1.0.0.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1.545 s
[INFO] Finished at: 2026-11-14T09:31:14-06:00
[INFO] ------------------------------------------------------------------------Explanation: clean deleted the old target folder. Asking for package then ran the phases before it in order: copy resources, compile the 3 main classes, compile the test class, run the 3 tests, and finally build target/fee-app-1.0.0.jar. Each --- name:version:goal --- line is a plugin goal attached to a phase; Maven itself only coordinates plugins. If a test had failed, the build would have stopped there with BUILD FAILURE and no JAR.
To run the program with its dependencies on the class path, the Exec plugin is the quickest way:
mvn -q compile exec:java -Dexec.mainClass=school.fees.App{"name":"Navika","baseFee":15000,"lateDays":3}
Total: 16500| Command | Description |
|---|---|
mvn compile | It is used to compile the main code into target/classes. |
mvn test | It compiles everything and runs the tests. |
mvn package | It compiles, tests and builds the JAR in target/. |
mvn install | It also copies the JAR into the local cache, so your other projects can use it. |
mvn clean | It deletes target/. Often combined: mvn clean package. |
mvn dependency:tree | It shows every library the project uses and why. |
mvn -q, mvn -o | They run quietly, or offline using only the local cache. |
Gradle
Here is the same project built with Gradle. The src folder is identical; only the build files change.
rootProject.name = "fee-app"plugins {
application
}
group = "com.example.school"
version = "1.0.0"
repositories {
mavenCentral()
}
dependencies {
implementation("com.google.code.gson:gson:2.15.0")
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
application {
mainClass = "school.fees.App"
}
tasks.test {
useJUnitPlatform()
}Explanation:
- The
applicationplugin adds Java compilation plusrunand distribution tasks. repositories { mavenCentral() }: Gradle uses the same Maven Central libraries and the same coordinates, written as one string"group:artifact:version".implementationis a library the code needs;testImplementationis for tests only, like Maven'stestscope.- The toolchain asks for JDK 25 to compile and run, whatever JDK started Gradle.
useJUnitPlatform()tells thetesttask to run JUnit 5 and later tests.
Building with Gradle
Gradle works with tasks that depend on each other. build depends on assemble (compile and package) and check (run the tests):
./gradlew clean build --console=plain> Task :clean
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :jar
> Task :startScripts
> Task :distTar
> Task :distZip
> Task :assemble
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build
BUILD SUCCESSFUL in 602ms
8 actionable tasks: 8 executed./gradlew run -q{"name":"Navika","baseFee":15000,"lateDays":3}
Total: 16500Explanation: Gradle compiled, packaged build/libs/fee-app-1.0.0.jar, made ready-to-ship .zip and .tar distributions with start scripts, and ran the tests, all in well under a second. Gradle is incremental: run build again without changing anything and every task is reported UP-TO-DATE and skipped.
The Wrapper
./gradlew is the Gradle wrapper: a small script committed with the project that downloads the exact Gradle version the project needs, the first time it runs. Nobody has to install Gradle, and everyone uses the same version. Maven has the same idea, ./mvnw, created with mvn wrapper:wrapper.
| Gradle command | Description |
|---|---|
./gradlew build | It is used to compile, test and package the project. |
./gradlew test | It runs the tests. |
./gradlew run | It runs the main class (with the application plugin). |
./gradlew clean | It deletes the build/ folder. |
./gradlew dependencies | It shows the dependency tree. |
./gradlew tasks | It lists the available tasks. |
Maven vs Gradle
| Point | Maven | Gradle |
|---|---|---|
| Build file | pom.xml (XML, declarative) | build.gradle.kts (Kotlin) or build.gradle (Groovy) |
| Model | Fixed lifecycle of phases | Graph of tasks |
| Flexibility | Strict conventions; custom logic needs a plugin | Any custom logic can be written in the script |
| Speed | Good | Faster on large builds: incremental tasks and a build cache |
| Learning curve | Easier to read and predict | More to learn, more power |
| Common in | Enterprise and Spring projects | Android, large multi-module builds |
Both download from the same repositories and share the same project layout, so moving a project from one to the other is mostly rewriting the build file.
Common Mistakes
Committing target/ or build/. They are generated output. Add them to .gitignore (see "Git Basics for Java Projects").
Putting tests in src/main/java. They would be compiled into the JAR, and test libraries with test scope would not be found. Tests go in src/test/java.
Skipping tests to get a green build. mvn package -DskipTests hides problems; fix the failing test instead.
Not pinning versions. Without plugin and library versions, builds can change on their own when a new version is released.
Installing Gradle globally and ignoring the wrapper. A different Gradle version can fail on the project's script. Always use ./gradlew.
Practice
- Easy: Create the
fee-appproject with the layout above and thepom.xml, runmvn package, and open the JAR withjar tf target/fee-app-1.0.0.jar. Check that no test class is inside. - Harder: Add the Gradle files to the same folder and run
./gradlew buildtwice. Compare the task list of the second run with the first, then change one line inApp.javaand see which tasks run again.
Key Takeaways
Was this tutorial helpful?