Skip to content

08 · Build Tools Deep Dive

Level 1, Module 9 introduced Maven's pom.xml and the basic compile/test/package commands. This module goes deeper into the full lifecycle, dependency scopes, and Gradle as an alternative.

The full Maven lifecycle

Maven organizes a build into a sequence of numbered phases. Running any phase automatically runs every phase before it, in order:

validate  -> compile -> test -> package -> verify -> install -> deploy
Phase What happens
validate Checks the project structure and pom.xml are correct
compile Compiles src/main/java into target/classes
test Compiles and runs src/test/java against a test framework (e.g. JUnit)
package Bundles compiled code into a distributable .jar (or .war)
verify Runs additional checks (e.g. integration tests) on the packaged artifact
install Copies the artifact into your local ~/.m2 repository, for other local projects to depend on
deploy Publishes the artifact to a shared/remote repository for other people's projects
mvn compile    # runs validate, compile
mvn test       # runs validate, compile, test
mvn package    # ...through package -- produces target/my-app-1.0.0.jar
mvn install    # ...through install -- available to other local Maven projects

mvn clean is special — it isn't part of the main lifecycle above, it just deletes target/. It's commonly chained: mvn clean package.

Dependency scopes

Every <dependency> in pom.xml has a scope controlling when it's available and whether it ships with the final artifact:

<dependencies>
    <!-- default scope: available everywhere, bundled in the final artifact -->
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>33.0.0-jre</version>
        <scope>compile</scope>
    </dependency>

    <!-- only available while compiling/running tests, never shipped -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>

    <!-- needed to compile against, but supplied by the runtime environment
         (e.g. a servlet container providing the Servlet API) -->
    <dependency>
        <groupId>jakarta.servlet</groupId>
        <artifactId>jakarta.servlet-api</artifactId>
        <version>6.0.0</version>
        <scope>provided</scope>
    </dependency>

    <!-- needed only at runtime, not for compiling your own code against -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <version>8.3.0</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>
Scope Available at compile time Available at test time Bundled/shipped
compile (default) Yes Yes Yes
test No Yes No
provided Yes Yes No (assumed present at runtime)
runtime No Yes Yes

Multi-module basics

A large project can be split into several Maven modules sharing one parent pom.xml. The parent declares <modules>; each child has its own pom.xml with <parent> pointing back up:

<!-- parent pom.xml -->
<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>inventory-parent</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <modules>
        <module>inventory-core</module>
        <module>inventory-cli</module>
    </modules>
</project>

Running mvn install from the parent directory builds every module in dependency order. This is enough to recognize the pattern when you meet it — full multi-module configuration is out of scope for this course.

Gradle as an alternative

Gradle uses a script-based configuration (Groovy or, more commonly today, the Kotlin DSL, build.gradle.kts) instead of Maven's declarative XML. Here is a rough Gradle equivalent of the JUnit-enabled pom.xml from Module 7:

// build.gradle.kts
plugins {
    id("java")
}

group = "com.example"
version = "1.0.0"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
}

java {
    sourceCompatibility = JavaVersion.VERSION_21
    targetCompatibility = JavaVersion.VERSION_21
}

tasks.test {
    useJUnitPlatform()
}
gradle build     # if Gradle is installed globally
./gradlew build  # using the "Gradle wrapper" checked into the project -- preferred,
                 # guarantees everyone uses the same Gradle version

Maven vs. Gradle

Maven Gradle
Config format XML (pom.xml) Groovy or Kotlin DSL (build.gradle[.kts])
Philosophy Convention over configuration, declarative Flexible, imperative/declarative mix
Dependency scopes compile, test, provided, runtime implementation, testImplementation, compileOnly, runtimeOnly
Build/test command mvn package / mvn test gradle build / gradle test
Wrapper mvnw gradlew

Both download dependencies from the same shared repository (Maven Central) and solve the same underlying problem — the choice is largely ecosystem and team convention.

How It Actually Works

Maven's dependency resolution builds a full dependency graph, not a flat list — when two dependencies transitively pull in different versions of the same library, Maven's "nearest wins" rule (shortest path in the graph, ties broken by declaration order) picks exactly one version, and every one of your compiled classes that reference that library get linked against whichever version wins, silently, at build time — this is the mechanical root cause of dependency-version conflicts that surface only at runtime as NoSuchMethodError when the resolved JAR is missing a method your code was compiled against.

Gradle's incremental build model is why it's typically faster on repeated builds: each task declares its inputs/outputs, and Gradle hashes them to build a task graph, skipping any task whose inputs are unchanged since the last run (UP-TO-DATE) — genuinely reusing prior work rather than a caching illusion, and it can go further with a build cache keyed on those same input hashes, sharing results across machines.

Both tools ultimately just orchestrate javac (or a compiler daemon that keeps the compiler warm across builds to skip JVM startup cost) and package the resulting .class files into a JAR — the build tool adds dependency resolution and lifecycle sequencing on top of primitives you could still run by hand with javac/jar.

Exercise

Take the pom.xml from Module 7 and add a second dependency for the Guava library (com.google.guava:guava:33.0.0-jre) with the default compile scope, then a third dependency for logback-classic with runtime scope. Then write the Gradle (build.gradle.kts) equivalent of that full three-dependency pom.xml from scratch, matching each Maven scope to its Gradle configuration name from the table above.