Skip to content
elephantoo

Build tools: Maven & Gradle

Lesson 40 of 43 16 min read

Project layout, pom.xml and build.gradle.kts, dependencies, lifecycle, running tests and packaging.


So far we compiled with javac, ran with java, and downloaded JARs by hand. That falls apart quickly: a real project has dozens of dependencies (which have their own dependencies), tests to run, and artifacts to package for deployment. Build tools automate all of it. The Java world has two big ones: Maven and Gradle. Every professional Java developer uses at least one daily.

What a build tool does#

  • Dependency management: declare gson:2.14.0, and the tool downloads it, plus everything it needs (transitive dependencies), from Maven Central, then caches it locally.
  • A standard project layout, so any developer (and any IDE) understands your project instantly.
  • Compiling, testing and packaging with one command.
  • Reproducible builds on every machine and in CI (GitHub Actions, Jenkins...).
  • Plugins for everything else: code coverage, formatting, Docker images, Spring Boot.

The standard layout#

Both Maven and Gradle use the same conventional structure:

Output
tip-calculator/
├── pom.xml                  (Maven)  or  build.gradle.kts + settings.gradle.kts (Gradle)
└── src/
    ├── main/
    │   ├── java/            production code, organised by package
    │   │   └── com/elephantoo/tips/App.java
    │   └── resources/       config files, templates (copied onto the classpath)
    └── test/
        ├── java/            test code, same packages as the code under test
        │   └── com/elephantoo/tips/TipCalculatorTest.java
        └── resources/

Build output goes to target/ (Maven) or build/ (Gradle). Never commit those folders; add them to .gitignore.

Installing#

Terminal
# Ubuntu / Debian
sudo apt install maven
# Fedora
sudo dnf install maven
# macOS
brew install maven gradle

mvn -v

For Gradle you rarely install anything globally: projects include the Gradle Wrapper (./gradlew), and IntelliJ IDEA can create either kind of project for you. SDKMAN! (sdk install gradle) is a convenient way to get the latest Gradle on Linux and macOS.

Our example project#

A tiny app that calculates tips and prints JSON using Google's Gson library:

src/main/java/com/elephantoo/tips/TipCalculator.java
package com.elephantoo.tips;

import java.math.BigDecimal;
import java.math.RoundingMode;

public class TipCalculator {
    public BigDecimal tip(BigDecimal bill, int percent) {
        if (bill.signum() < 0 || percent < 0) {
            throw new IllegalArgumentException("Bill and percent must not be negative");
        }
        return bill.multiply(BigDecimal.valueOf(percent))
                   .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
    }
}
src/main/java/com/elephantoo/tips/App.java
package com.elephantoo.tips;

import com.google.gson.Gson;
import java.math.BigDecimal;

public class App {
    record Receipt(BigDecimal bill, BigDecimal tip) { }

    public static void main(String[] args) {
        BigDecimal bill = new BigDecimal(args.length > 0 ? args[0] : "1250");
        BigDecimal tip = new TipCalculator().tip(bill, 10);
        System.out.println(new Gson().toJson(new Receipt(bill, tip)));
    }
}
src/test/java/com/elephantoo/tips/TipCalculatorTest.java
package com.elephantoo.tips;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.math.BigDecimal;
import org.junit.jupiter.api.Test;

class TipCalculatorTest {
    private final TipCalculator calc = new TipCalculator();

    @Test
    void tenPercentOfTwelveFifty() {
        assertEquals(new BigDecimal("125.00"), calc.tip(new BigDecimal("1250"), 10));
    }

    @Test
    void rejectsNegativeBill() {
        assertThrows(IllegalArgumentException.class, () -> calc.tip(new BigDecimal("-1"), 10));
    }
}

BigDecimal is used because this is money, and the test checks two behaviours. Tests are covered properly in the next lesson.

Maven#

Maven describes a project with an XML file, the POM (Project Object Model):

pom.xml
<?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.elephantoo</groupId>
    <artifactId>tip-calculator</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.release>21</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.junit</groupId>
                <artifactId>junit-bom</artifactId>
                <version>5.14.4</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.14.0</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.15.0</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.6</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.5.0</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>com.elephantoo.tips.App</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

What the parts mean:

  • Coordinates groupId:artifactId:version identify your project, exactly as com.google.code.gson:gson:2.14.0 identifies Gson. A version ending in -SNAPSHOT means "work in progress".
  • maven.compiler.release = 21 compiles for Java 21.
  • <dependencies>: libraries your code needs. The BOM import in <dependencyManagement> pins versions for a family of artifacts (all JUnit modules), so the junit-jupiter dependency needs no version of its own.
  • <build><plugins>: pinning plugin versions makes builds reproducible. The JAR plugin writes Main-Class into the manifest.

Dependency scopes

Maven scopeGradle configurationAvailable whenExample
compile (default)implementationcompile + test + runtimeGson, Spring
runtimeruntimeOnlytest + runtime (not compile)JDBC drivers
testtestImplementationcompiling and running tests onlyJUnit, Mockito
providedcompileOnlycompile only; supplied by the environmentServlet API, Lombok

The build lifecycle

Maven has fixed phases; running one runs all the phases before it:

Output
validate → compile → test → package → verify → install → deploy
Terminal
mvn compile        # compile src/main/java into target/classes
mvn test           # compile + run tests from src/test/java
mvn package        # ...and build target/tip-calculator-1.0.0.jar
mvn install        # ...and copy the JAR into your local repository (~/.m2/repository)
mvn clean package  # delete target/ first, then build from scratch
mvn -DskipTests package   # skip running tests (use sparingly!)

Running mvn test prints a test summary:

Output
[INFO] Running com.elephantoo.tips.TipCalculatorTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.039 s -- in com.elephantoo.tips.TipCalculatorTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

If any test fails, the build fails, and nothing broken gets packaged. That's exactly what you want in CI.

Running the application

Terminal
mvn -q compile exec:java -Dexec.mainClass=com.elephantoo.tips.App -Dexec.args=800
Output
{"bill":800,"tip":80.00}

Watch out: java -jar target/tip-calculator-1.0.0.jar fails with NoClassDefFoundError: com/google/gson/Gson. A plain JAR contains only your classes, not your dependencies. Options:

Terminal
# copy runtime dependencies next to the JAR and put them all on the classpath
mvn package dependency:copy-dependencies -DincludeScope=runtime
java -cp "target/tip-calculator-1.0.0.jar:target/dependency/*" com.elephantoo.tips.App 99.99
Output
{"bill":99.99,"tip":10.00}

Or build a single "fat" JAR containing everything, with the maven-shade-plugin (or Gradle's Shadow plugin). Spring Boot's build plugins do this for you automatically.

Inspecting dependencies

Terminal
mvn dependency:tree
Output
[INFO] +- com.google.code.gson:gson:jar:2.14.0:compile
[INFO] |  \- com.google.errorprone:error_prone_annotations:jar:2.48.0:compile
[INFO] \- org.junit.jupiter:junit-jupiter:jar:5.14.4:test
[INFO]    +- org.junit.jupiter:junit-jupiter-api:jar:5.14.4:test
[INFO]    |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO]    |  +- org.junit.platform:junit-platform-commons:jar:1.14.4:test
[INFO]    |  \- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO]    +- org.junit.jupiter:junit-jupiter-params:jar:5.14.4:test
[INFO]    \- org.junit.jupiter:junit-jupiter-engine:jar:5.14.4:test
[INFO]       \- org.junit.platform:junit-platform-engine:jar:1.14.4:test

You declared two dependencies and got ten: those are transitive dependencies. When two libraries need different versions of the same artifact, Maven picks the one nearest to your project in this tree. dependency:tree is the first tool to reach for when you see NoSuchMethodError or version conflicts.

Find libraries and their latest versions on Maven Central. Keep them up to date for security fixes.

Gradle#

Gradle builds are written as code, usually in the Kotlin DSL (build.gradle.kts; older projects use Groovy build.gradle). The same project:

settings.gradle.kts
rootProject.name = "tip-calculator"
build.gradle.kts
plugins {
    application
}

group = "com.elephantoo"
version = "1.0.0"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.code.gson:gson:2.14.0")

    testImplementation(platform("org.junit:junit-bom:5.14.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

application {
    mainClass = "com.elephantoo.tips.App"
}

tasks.test {
    useJUnitPlatform()
}
  • The application plugin adds Java support plus run and distribution tasks.
  • A toolchain tells Gradle to compile and test with Java 21, downloading it if necessary.
  • Dependencies use the same Maven coordinates, from the same Maven Central.

Common Gradle tasks

Terminal
gradle wrapper              # once: generate ./gradlew and gradle/wrapper/ (commit them!)
./gradlew build             # compile, test, and assemble build/libs/*.jar
./gradlew test              # run tests (HTML report in build/reports/tests/test/index.html)
./gradlew run --args=800    # run the application
./gradlew clean             # delete build/
./gradlew dependencies      # dependency tree
./gradlew tasks             # list available tasks
Output
{"bill":800,"tip":80.00}

./gradlew build also creates build/distributions/tip-calculator-1.0.0.zip, containing your JAR, all dependency JARs and start scripts. Unzip it anywhere and run bin/tip-calculator. On Windows, use gradlew.bat.

Gradle is fast thanks to incremental builds (only redo what changed), a build cache, and a background daemon.

Maven or Gradle?#

MavenGradle
Build fileXML (pom.xml), declarativeKotlin/Groovy code
Learning curvegentle, very predictablesteeper, very flexible
Speedgoodusually faster (incremental, caching)
Customisationvia pluginsplugins or custom code
Common inenterprise and Spring projectsAndroid (standard), many modern back-ends

Both are excellent, and both use Maven Central. Learn to read both. Your team or framework usually decides; for example, Spring Initializr offers either.

Multi-module projects (briefly)#

Large applications are often split into modules (shop-domain, shop-api, shop-web). Maven uses a parent POM with <modules>; Gradle uses include("shop-domain", "shop-api") in settings.gradle.kts. Each module has its own build file and can depend on the others.

Common mistakes#

  • Putting code in the wrong folder (src/java instead of src/main/java), so nothing compiles or tests are not found.
  • Forgetting useJUnitPlatform() in Gradle, or using an ancient Surefire version in Maven, so tests silently don't run (look for "Tests run: 0").
  • Expecting java -jar on a plain JAR to include dependencies.
  • Committing target/ or build/, or not committing the Gradle Wrapper.
  • Unpinned or outdated dependency versions.
  • Using compile/implementation scope for test libraries.

What's next#

Our build already ran two tests. Next, we learn to write good ones: unit testing with JUnit 5.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In Maven, which dependency scope makes a library available only when compiling and running tests?

  2. 2.What does mvn package do?

  3. 3.Why commit the Gradle Wrapper (gradlew, gradle/wrapper/) to version control?

Finished reading?

Mark this lesson complete to track your progress.