The previous lesson ended with an uncomfortable question: what exactly is mvn test?

You have typed it five times. You have put <scope>test</scope> on a dependency without knowing what it means. You have seen spring-boot-starter-parent, maven-surefire-plugin and spring-boot-maven-plugin appear in a pom.xml and taken them on trust. You have run mvn spring-boot:run and mvn dependency:tree. And in 11-01 there was talk of groupId:artifactId:version coordinates as preparation for this lesson.

This lesson explains the tool that has been holding up the previous three.

Maven is the standard build tool of the Java ecosystem. Its job is to turn your source code and a configuration file into a runnable artifact, resolving the dependencies along the way, running the tests and guaranteeing that the process gives the same result on any machine.

That last point is the most underrated one. Up to module 10, BiblioTech was compiled with javac and a hand-written classpath. With the forty jars Spring Boot has brought in, that is no longer tedious: it is impossible. And there is an underlying reason that goes beyond convenience, and that links back to Log4Shell (11-01): the ability to respond to a vulnerability depends on being able to change a version with one line and verify with one command that nothing broke. You got the second half in 11-04 with JUnit. Here comes the first.

By the end you will understand what problem a build tool solves; you will be able to read and write a complete pom.xml; you will master dependencies, their scopes, transitivity and conflict resolution; you will know the life cycle and its phases; you will know what a plugin is and what the ones you use do; you will handle profiles and multi-module projects; and you will have the judgement to choose between Maven and Gradle.

And BiblioTech will be a complete, reproducible Maven project.

Contents

  1. The problem: javac by hand
  2. What a build tool solves
  3. Convention over configuration
  4. The standard directory layout
  5. Installation and verification
  6. The POM: coordinates
  7. SNAPSHOT versus a released version
  8. properties: POM variables
  9. BiblioTech's complete pom.xml
  10. Dependencies: declaration and repositories
  11. Dependency scopes
  12. Transitive dependencies
  13. Conflict resolution: the nearest definition
  14. mvn dependency:tree in practice
  15. exclusions
  16. dependencyManagement versus dependencies
  17. The BOM and spring-boot-starter-parent
  18. The life cycle: three chains
  19. The phases of the default chain
  20. Plugins and goals
  21. The plugins BiblioTech uses
  22. surefire versus failsafe
  23. Packaging: maven-jar-plugin, shade and spring-boot-maven-plugin
  24. Profiles
  25. Multi-module projects
  26. settings.xml and credentials
  27. The Maven Wrapper
  28. Day-to-day commands
  29. Reproducibility and pinning versions
  30. Maven versus Gradle
  31. BiblioTech as a complete Maven project
  32. Common Mistakes and Tips
  33. Exercises

  1. The problem: javac by hand

Let us go back to module 1. This is how BiblioTech was compiled and run:

javac -d classes src/com/nexussoftware/bibliotech/*.java
java -cp classes com.nexussoftware.bibliotech.Main

It works with ten files and zero dependencies. Now look at what it would take today:

javac -d target/classes \
      -cp "lib/spring-core-6.1.11.jar:lib/spring-context-6.1.11.jar:lib/spring-beans-6.1.11.jar:lib/spring-aop-6.1.11.jar:lib/spring-boot-3.3.2.jar:lib/spring-boot-autoconfigure-3.3.2.jar:lib/hibernate-core-6.5.2.jar:lib/jakarta.persistence-api-3.1.0.jar:lib/h2-2.2.224.jar:lib/jackson-databind-2.17.2.jar:lib/slf4j-api-2.0.13.jar:lib/logback-classic-1.5.6.jar:..." \
      $(find src/main/java -name "*.java")

And that only compiles. Afterwards you would have to:

  1. Copy the resources from src/main/resources into target/classes.
  2. Compile the tests with another classpath (the production one plus JUnit, AssertJ and Mockito).
  3. Run the tests by invoking JUnit's launcher by hand.
  4. Package it into a jar with a correct MANIFEST.MF.
  5. And before any of that, download the forty jars by hand, with their exact, mutually compatible versions.

Point 5 is the killer. Downloading spring-boot-starter-data-jpa means finding out that it needs Hibernate, that Hibernate needs jakarta.persistence-api, ByteBuddy, Jandex, Classmate and Antlr, that ByteBuddy needs... and doing it with versions that do not clash. By hand it is days of work that has to be redone at every update.

It is called dependency hell, and it is the main problem Maven solves.

  1. What a build tool solves

Problem Without a tool With Maven
Compiling javac with a hand-written classpath mvn compile
Dependencies Downloading jars by hand Declaring them in the POM
Transitive dependencies Working them out and downloading one by one Automatic
Version conflicts Trial and error A deterministic rule
Running tests Invoking the launcher by hand mvn test, automatic
Packaging jar cvfm with a hand-written manifest mvn package
Resources cp by hand Automatic
Reproducibility Depends on the machine Guaranteed
IDE integration Configuring each one The IDE reads the POM
Continuous integration A bespoke script mvn verify

And a cultural consequence that matters: every Maven project builds the same way. Somebody hands you an unfamiliar repository, you see a pom.xml, you type mvn package and it works. No documentation to read, nobody to ask.

  1. Convention over configuration

This is Maven's design principle, and it explains why its configuration is so short.

If you follow the conventions, you do not have to configure anything. You only configure what departs from them.

Maven assumes that:

  • The source code is in src/main/java.
  • The resources are in src/main/resources.
  • The tests are in src/test/java.
  • The test resources are in src/test/resources.
  • Everything generated goes into target/.
  • Every *Test.java file is a test.

That is why in 11-04 you put the tests in src/test/java and mvn test found them without you configuring anything. It was not magic: it was the convention.

The alternative —explicit configuration, like Ant— requires declaring every path, every task and every dependency between tasks. The result is three-hundred-line build files, different in every project, that you have to read before understanding anything.

The trade-off is real: if your project does not fit the conventions, Maven becomes awkward. It is the price of uniformity, and for the vast majority of Java projects it is worth paying.

  1. The standard directory layout

bibliotech/
├── pom.xml                    <- THE configuration file
├── mvnw, mvnw.cmd             <- Maven Wrapper (section 27)
├── .mvn/wrapper/              <- wrapper configuration
├── src/
│   ├── main/
│   │   ├── java/              <- production code
│   │   │   └── com/nexussoftware/bibliotech/
│   │   └── resources/         <- application.yml, data.sql, logback.xml
│   └── test/
│       ├── java/              <- tests
│       │   └── com/nexussoftware/bibliotech/
│       └── resources/         <- application-test.yml, test CSVs
└── target/                    <- EVERYTHING generated (never in git)
    ├── classes/               <- production .class files + resources
    ├── test-classes/          <- test .class files
    ├── surefire-reports/      <- test reports (11-04)
    ├── generated-sources/     <- generated code (Lombok, MapStruct)
    └── bibliotech-1.0.0-SNAPSHOT.jar
graph TD
    A["src/main/java"] -->|compile| B["target/classes"]
    R["src/main/resources"] -->|process-resources| B
    C["src/test/java"] -->|test-compile| D["target/test-classes"]
    TR["src/test/resources"] -->|process-test-resources| D
    B --> D
    D -->|test: surefire| E["target/surefire-reports"]
    B -->|package| F["target/bibliotech-1.0.0-SNAPSHOT.jar"]
    F -->|install| G["~/.m2/repository"]

Two golden rules:

  1. target/ never goes into git. It is all regenerable. Your .gitignore must contain target/.
  2. src/ is never touched by hand from the build. It is the source, and it is the only thing in the repository besides the POM.

  1. Installation and verification

mvn -version
Apache Maven 3.9.8
Maven home: /opt/maven
Java version: 17.0.11, vendor: Eclipse Adoptium
Default locale: en_GB, platform encoding: UTF-8
OS name: "linux", version: "6.12.0", arch: "amd64"

Check three things: Maven's version (3.9.x is the current one), Java's (17 or above for this course) and the platform encoding, which must be UTF-8 to avoid problems with accented characters.

If you do not have it installed, section 27 will give you a very good reason not to need it.

Creating a project from scratch:

mvn archetype:generate \
    -DgroupId=com.nexussoftware \
    -DartifactId=bibliotech \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.4 \
    -DinteractiveMode=false

In practice, for a Spring Boot project people use Spring Initializr (start.spring.io), which generates the POM, the wrapper and the main class already configured.

  1. The POM: coordinates

The POM (Project Object Model) is the pom.xml: the complete description of the project.

<?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>   <!-- always 4.0.0 -->

    <!-- THE COORDINATES: they identify the artifact uniquely -->
    <groupId>com.nexussoftware</groupId>
    <artifactId>bibliotech</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>BiblioTech</name>
    <description>Management of Nexus Software's internal technical library</description>
</project>
Coordinate What it is Convention
groupId The organisation Reversed domain: com.nexussoftware
artifactId The module Lowercase with hyphens: bibliotech-core
version The version SemVer (11-01): 1.0.0
packaging What is produced jar (default), war, pom

The first three form the groupId:artifactId:version coordinates you already saw in 11-01. They determine the path in the repository:

~/.m2/repository/com/nexussoftware/bibliotech/1.0.0-SNAPSHOT/bibliotech-1.0.0-SNAPSHOT.jar

Values for packaging:

Value Produces Use
jar A .jar Libraries and applications (the normal case)
war A .war Web applications for an external server (little used today)
pom Only the POM Parent or aggregator project (section 25)

  1. SNAPSHOT versus a released version

The version has two very different natures:

Type Example Meaning Behaviour
SNAPSHOT 1.0.0-SNAPSHOT Under development Mutable: Maven re-downloads it periodically
Released 1.0.0 Published Immutable: downloaded once and cached forever

The practical consequence: if you depend on bibliotech-core:1.0.0-SNAPSHOT, Maven checks daily whether there is a newer version. If you depend on 1.0.0, it downloads it once and never looks again.

Professional rule: a released version is never overwritten. If 1.0.0 has a bug, 1.0.1 is published. Republishing 1.0.0 with different content breaks reproducibility for everybody who already had it cached, and it is one of the worst things you can do in a shared repository.

And its corollary: never depend on a SNAPSHOT in production. It builds today and tomorrow with different content.

Forcing snapshots to update:

mvn -U clean install     # -U = update-snapshots

  1. properties: POM variables

<properties>
    <java.version>17</java.version>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

    <!-- centralised versions, so they are not repeated -->
    <assertj.version>3.25.3</assertj.version>
    <mockito.version>5.12.0</mockito.version>
</properties>

They are used with ${name}:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>${assertj.version}</version>
</dependency>

The advantage: when fifteen dependencies share a version (the whole Jackson ecosystem, for instance), you change it in one place.

project.build.sourceEncoding is genuinely important: without it, Maven uses the platform encoding, and building on a Spanish Windows machine and on a UTF-8 Linux server can produce different results. Maven emits a WARNING if it is missing.

There are also useful predefined properties: ${project.version}, ${project.artifactId}, ${project.basedir}, ${maven.build.timestamp}.

  1. BiblioTech's complete pom.xml

This is the complete, commented POM, with everything the project needs after four lessons:

<?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>

    <!-- ================= PARENT ================= -->
    <!-- Provides the BOM (compatible versions of EVERYTHING) and plugin configuration -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
        <relativePath/>   <!-- empty: look for it in the repository, not on disk -->
    </parent>

    <!-- ================= IDENTITY ================= -->
    <groupId>com.nexussoftware</groupId>
    <artifactId>bibliotech</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>BiblioTech</name>
    <description>Management of Nexus Software's internal technical library</description>

    <!-- ================= PROPERTIES ================= -->
    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <!-- ================= DEPENDENCIES ================= -->
    <dependencies>

        <!-- Spring core: IoC container, autoconfiguration, logging (11-02) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <!-- Validation: @NotNull, @Min on @ConfigurationProperties (11-02) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- AOP: @Transactional and our own aspects (11-02) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!-- JPA + Hibernate + Spring Data + transactions (11-03) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- H2: in-memory database. runtime: not needed to compile -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- @ConfigurationProperties metadata: IDE autocompletion -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Testing: JUnit 5, AssertJ, Mockito, spring-test (11-04, 11-06) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <!-- ================= BUILD ================= -->
    <build>
        <plugins>

            <!-- Packages the executable jar and provides the spring-boot:run goal (11-02) -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>

            <!-- UNIT tests: *Test.java -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <configuration>
                    <excludedGroups>integration</excludedGroups>   <!-- @Tag from 11-04 -->
                </configuration>
            </plugin>

            <!-- INTEGRATION tests: *IT.java, in the verify phase -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-failsafe-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>integration-test</goal>
                            <goal>verify</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

    <!-- ================= PROFILES ================= -->
    <profiles>
        <profile>
            <id>fast</id>   <!-- mvn package -Pfast : no slow tests -->
            <properties>
                <skipITs>true</skipITs>
            </properties>
        </profile>
    </profiles>
</project>

An important detail: no Spring dependency carries a <version>. The parent manages them. We will come back to that in section 17.

  1. Dependencies: declaration and repositories

A dependency is declared with its coordinates:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.2</version>
    <scope>compile</scope>       <!-- the default; it can be omitted -->
</dependency>

Where does the jar come from? Maven looks in three places, in this order:

graph LR
    A["mvn compile"] --> B{"Is it in<br/>~/.m2/repository?"}
    B -->|Yes| E["It is used"]
    B -->|No| C{"Is there a corporate<br/>repository (Nexus,<br/>Artifactory)?"}
    C -->|Yes| D["Downloaded from it"]
    C -->|No| F["Downloaded from<br/>Maven Central"]
    D --> G["Stored in ~/.m2"]
    F --> G
    G --> E
Repository What it is When
Local (~/.m2/repository) A cache on your disk Always checked first
Central The public repository (11-01) By default
Corporate Nexus, Artifactory: a mirror plus private artifacts In a company

Adding an extra repository (avoid it if you can):

<repositories>
    <repository>
        <id>nexus-internal</id>
        <url>https://nexus.nexussoftware.com/repository/maven-public/</url>
    </repository>
</repositories>

Professional tip: the fewer repositories, the better. Each one adds a source of artifacts you have to trust. The usual practice in a company is to configure a single corporate mirror in settings.xml (section 26) that in turn replicates Central: that way there is one control point, cached and auditable.

  1. Dependency scopes

The scope determines which classpath a dependency is available on and whether it is packaged.

Scope Compile Test Run Transitive? Example
compile (default) Yes Yes Yes Yes Spring, Jackson
provided Yes Yes No No The Servlet API in a .war; Lombok
runtime No Yes Yes Yes The H2 driver, Logback
test No Yes No No JUnit, AssertJ, Mockito
system Yes Yes No No Obsolete, do not use it
import — — — — Only in dependencyManagement: imports a BOM

The two you have already used without knowing it:

runtime for H2. Your code never writes import org.h2.Driver. Only the running JVM needs it, when Hibernate loads the driver by name. Putting it in runtime means you cannot use it in your code by accident: if somebody writes a class that imports something from H2, it does not compile. That is a free architectural barrier.

test for JUnit and AssertJ. They are available when compiling and running tests, and they are not packaged into the final jar. That is what keeps your production application from carrying a testing framework inside it — with its weight and its attack surface.

provided deserves a note: it means "at run time this will already be there, do not package it". Its classic case was the Servlet API in a .war, provided by the application server. Today, with embedded servers, it is used mainly for Lombok (11-07), which only acts at compile time.

Seeing the resulting classpath:

mvn dependency:build-classpath -Dmdep.outputFile=classpath.txt

  1. Transitive dependencies

Here is the feature that makes Maven worth it.

When you declare one dependency, Maven also downloads its own, and theirs, recursively.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

That single declaration brings in, among others:

spring-boot-starter-data-jpa
├── spring-boot-starter-aop
│   ├── spring-aop
│   └── aspectjweaver
├── spring-boot-starter-jdbc
│   ├── HikariCP                   <- connection pool
│   └── spring-jdbc
├── jakarta.persistence-api        <- the specification (11-03)
├── jakarta.transaction-api
├── hibernate-core                 <- the implementation (11-03)
│   ├── byte-buddy                 <- generates the lazy-loading proxies
│   ├── jandex
│   ├── classmate
│   └── antlr4-runtime             <- parses JPQL
├── spring-data-jpa
│   ├── spring-orm
│   └── spring-tx
└── spring-aspects

Nearly thirty artifacts you never named. Without Maven, you would have to find each one, work out the compatible version and download it by hand.

And there you can see something 11-03 took for granted: ByteBuddy is in your project because Hibernate uses it to generate the proxy subclasses for lazy loading. It is module 10's mechanism, with a name and coordinates.

An important rule about transitivity: test and provided dependencies do not propagate. If BiblioTech depended on a library that uses JUnit in its tests, you would not inherit JUnit. That is correct: somebody else's test dependencies are none of your business.

  1. Conflict resolution: the nearest definition

With thirty transitive dependencies, it is inevitable that two of them will ask for different versions of the same thing:

your-project
├── library-a  -> jackson-databind:2.15.0
└── library-b  -> jackson-databind:2.17.2

There can only be one version of a class on the classpath. Which one wins?

The nearest-definition rule (nearest definition wins): the version that is fewest hops away from your POM wins. On a tie, the one declared first wins.

your-project                                   (depth 0)
├── library-a                                  (1)
│   └── jackson-databind:2.15.0                (2)
├── jackson-databind:2.17.2                    (1)  <- WINS: it is nearer
└── library-b                                  (1)
    └── jackson-databind:2.16.0                (2)

A very useful practical consequence: declaring a dependency explicitly in your POM gives you absolute control over its version, because it will always be at depth 1 and beat any transitive one.

The rule is deterministic, but it can surprise you: "the newest" does not win, "the nearest" does. If a nearby transitive asks for an old version, that old one wins, and you can end up with a NoSuchMethodError at run time — the classic symptom of a version conflict.

Diagnosis:

mvn dependency:tree -Dverbose
[INFO] +- com.example:library-a:jar:1.2.0:compile
[INFO] |  \- (com.fasterxml.jackson.core:jackson-databind:jar:2.15.0:compile
             - omitted for conflict with 2.17.2)

That omitted for conflict with tells you exactly which version was discarded and in favour of which.

  1. mvn dependency:tree in practice

It is the diagnostic tool that will get you out of trouble most often. Four concrete uses:

1. Seeing the whole tree:

mvn dependency:tree

2. Finding who drags an artifact in (11-01's security question):

mvn dependency:tree -Dincludes=org.apache.logging.log4j:log4j-core
[INFO] com.nexussoftware:bibliotech:jar:1.0.0-SNAPSHOT
[INFO] \- com.example:legacy-library:jar:2.1.0:compile
[INFO]    \- org.apache.logging.log4j:log4j-core:jar:2.14.1:compile

There is the answer to "do I have Log4j and who pulled it in?", which in December 2021 cost half the industry weeks of work.

3. Seeing resolved conflicts:

mvn dependency:tree -Dverbose

4. Detecting dependencies declared but unused, or used but undeclared:

mvn dependency:analyze
[WARNING] Used undeclared dependencies found:
[WARNING]    org.slf4j:slf4j-api:jar:2.0.13:compile
[WARNING] Unused declared dependencies found:
[WARNING]    com.example:utils:jar:1.0.0:compile

That first warning points at a real risk: you are using SLF4J in your code but you get it transitively. If tomorrow Spring Boot stops bringing it in, your code stops compiling without you having touched anything. Whatever you use directly, declare directly.

  1. exclusions

Sometimes you have to remove something that arrives transitively. Real cases:

<!-- Case 1: replacing Logback with Log4j2 in Spring Boot -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<!-- Case 2: removing Tomcat in order to use Jetty or Undertow -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<!-- Case 3: removing a duplicate logging implementation that causes a conflict -->
<dependency>
    <groupId>com.example</groupId>
    <artifactId>legacy-library</artifactId>
    <version>2.1.0</version>
    <exclusions>
        <exclusion>
            <groupId>commons-logging</groupId>
            <artifactId>commons-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>

That third case is important and you will see it in 11-07: two logging implementations on the same classpath fight each other and the result is that nothing gets logged, or everything gets logged twice.

To exclude a dependency's entire tree in one go:

<exclusion>
    <groupId>*</groupId>
    <artifactId>*</artifactId>
</exclusion>

Use it carefully: it is easy to remove something that was needed and find out at run time.

  1. dependencyManagement versus dependencies

Two sections that are constantly confused.

Section What it does
<dependencies> Adds the dependency to the project
<dependencyManagement> Only declares the version that will be used if somebody adds it
<dependencyManagement>
    <dependencies>
        <!-- Does NOT add Jackson. It only says: "if it is used, it will be 2.17.2" -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.17.2</version>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- THIS does add it. With no version: it takes it from dependencyManagement -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

What it is for, three uses:

  1. Centralising versions in a multi-module project: the parent declares them, the modules use them without a version.
  2. Forcing a transitive's version without declaring it as a direct dependency. Very useful for patching a vulnerability: if a transitive has a CVE, here you impose the fixed version.
  3. Importing a BOM, which is what the next section is about.

Point 2 deserves an example, because it is the rapid-response tool against vulnerabilities:

<dependencyManagement>
    <dependencies>
        <!-- A transitive brings in 2.14.1, which is vulnerable. We force the fixed one -->
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>2.23.1</version>
        </dependency>
    </dependencies>
</dependencyManagement>

dependencyManagement always wins over transitive resolution, regardless of depth. It is the correct way to patch without waiting for the intermediate library to update.

  1. The BOM and spring-boot-starter-parent

A BOM (Bill of Materials) is a POM of type pom that contains only dependencyManagement: a catalogue of versions tested together.

It is used in two ways.

Way 1: inheriting from the parent (what BiblioTech does):

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.2</version>
</parent>

That gives you:

Inherited What it provides
dependencyManagement Compatible versions of more than 400 artifacts
pluginManagement Versions and configuration of the usual plugins
Properties java.version, encoding, and so on
Resource filtering application.yml with substitutable @property@
Plugin configuration spring-boot-maven-plugin already hooked to package

Way 2: importing the BOM (when you already have another parent, typical in a company):

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>3.3.2</version>
            <type>pom</type>
            <scope>import</scope>     <!-- the import scope from section 11 -->
        </dependency>
    </dependencies>
</dependencyManagement>

And now the warning that appeared in 11-02 makes complete sense:

Do not put a <version> on dependencies the BOM manages.

If you write <version>2.15.0</version> on Jackson, you are overriding the version the Spring Boot team tested with that version of Spring, of Hibernate and of the rest of the ecosystem. You can end up with subtle incompatibilities: a NoSuchMethodError at run time, six months later, in production.

If you really need another version, the correct way is to override the property:

<properties>
    <jackson.version>2.17.2</jackson.version>   <!-- name defined by the BOM -->
</properties>

  1. The life cycle: three chains

Maven has three independent life cycles, each with its own phases:

Cycle What for Main phases
clean Cleaning pre-clean, clean, post-clean
default Building validate ... deploy (the important one)
site Documentation pre-site, site, post-site, site-deploy
mvn clean          # runs the clean cycle: deletes target/
mvn package        # runs the default cycle up to the package phase
mvn clean package  # both, in that order

mvn site generates a website with project reports. It is little used today; most teams use other reporting tools.

  1. The phases of the default chain

And here is Maven's key idea:

Running a phase runs every previous phase.

That is why mvn test compiled your code in 11-04 without being asked: test comes after compile.

graph TD
    A["validate<br/>The project is correct"] --> B["compile<br/>src/main/java -> target/classes"]
    B --> C["test<br/>Runs *Test with surefire"]
    C --> D["package<br/>Packages into jar/war"]
    D --> E["verify<br/>Integration tests (failsafe)<br/>and quality checks"]
    E --> F["install<br/>Copies to ~/.m2/repository"]
    F --> G["deploy<br/>Uploads to the remote repository"]

The complete list, with the intermediate phases that also exist:

# Phase What it does
1 validate Checks that the POM is correct
2 generate-sources Generates source code
3 process-resources Copies src/main/resources to target/classes
4 compile Compiles src/main/java
5 process-test-resources Copies src/test/resources
6 test-compile Compiles src/test/java
7 test Runs the unit tests (surefire)
8 package Packages into a .jar
9 pre-integration-test Prepares the integration environment
10 integration-test Runs the integration tests (failsafe)
11 post-integration-test Tears the environment down
12 verify Checks the integration results
13 install Installs the artifact into ~/.m2/repository
14 deploy Uploads to the remote repository

Which to use in each situation:

Situation Command
Development, fast cycle mvn test
Producing the jar mvn package
Continuous integration mvn verify
Publishing for other local modules mvn install
Publishing to the corporate repository mvn deploy

A nuance about install: lots of people type mvn clean install out of habit for everything. It is slower than necessary and fills ~/.m2 with local artifacts. To verify, mvn verify is enough; install is only needed when another local project is going to consume your artifact.

  1. Plugins and goals

And here is the second key idea:

Maven does nothing by itself. Everything is done by plugins.

A phase has no code: it is a hook point. What happens at compile is that the compile goal of the maven-compiler-plugin plugin runs.

Concept What it is
Plugin A Maven artifact with runnable tasks
Goal One concrete task: compiler:compile, surefire:test
Binding The association between a goal and a phase

The default bindings for packaging=jar:

Phase Plugin:goal
process-resources maven-resources-plugin:resources
compile maven-compiler-plugin:compile
test-compile maven-compiler-plugin:testCompile
test maven-surefire-plugin:test
package maven-jar-plugin:jar
install maven-install-plugin:install
deploy maven-deploy-plugin:deploy

A goal can also be invoked directly, with no life cycle, using the plugin:goal syntax:

mvn dependency:tree        # dependency plugin, tree goal
mvn spring-boot:run        # spring-boot plugin, run goal    (11-02)
mvn versions:display-dependency-updates
mvn help:effective-pom     # shows the RESULTING POM after inheritance

That last one is a diagnostic gem: mvn help:effective-pom shows the complete POM after applying the parent's inheritance, with every version and configuration resolved. When you do not understand where a setting comes from, look there.

  1. The plugins BiblioTech uses

maven-compiler-plugin

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <release>17</release>          <!-- better than source+target -->
        <parameters>true</parameters>  <!-- keeps parameter names -->
        <compilerArgs>
            <arg>-Xlint:all</arg>
            <arg>-Werror</arg>         <!-- warnings = errors. Strict but healthy -->
        </compilerArgs>
    </configuration>
</plugin>

About <release>17</release> versus <source>/<target>: release is better because on top of setting the language level, it verifies that you only use API that exists in Java 17. With source/target you can compile code using a Java 21 method with JDK 21 and generate bytecode 17 that will fail at run time with NoSuchMethodError. release prevents that at compile time.

About <parameters>true</parameters>: it keeps the parameter names in the bytecode. Spring needs them (for @Value without an explicit name) and so does Jackson (11-07) to map constructors.

spring-boot-maven-plugin

You already used it in 11-02. It provides:

Goal What it does
spring-boot:run Runs the application without packaging it
spring-boot:repackage Turns the plain jar into an executable jar (hooked to package)
spring-boot:build-image Builds a container image (12-06)

maven-surefire-plugin and maven-failsafe-plugin

Both run tests. Their difference is the next section.

  1. surefire versus failsafe

surefire failsafe
Type of test Unit Integration
Phase test integration-test + verify
Name patterns *Test.java, Test*.java, *Tests.java *IT.java, IT*.java, *ITCase.java
If they fail It stops immediately Records the failure, runs post-integration-test and fails at verify
Speed Milliseconds Seconds

That difference in behaviour on failure is not a whim. An integration test may have started a database or a container in pre-integration-test. If failsafe stopped dead, those resources would be left dangling. That is why failsafe always lets the tear-down run and only fails when it reaches verify.

A practical consequence everybody falls for:

mvn test      # runs ONLY the unit tests. The *IT ones are NOT run.
mvn verify    # runs the unit tests AND the integration ones.

If you write LoanRepositoryIT.java and run mvn test, it does not run and there is no warning at all. Everything looks fine.

Applied to BiblioTech, with the tags from 11-04:

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <excludedGroups>integration,slow</excludedGroups>
    </configuration>
</plugin>

<plugin>
    <artifactId>maven-failsafe-plugin</artifactId>
    <configuration>
        <groups>integration</groups>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>integration-test</goal>
                <goal>verify</goal>
            </goals>
        </execution>
    </executions>
</plugin>

That way the fast development cycle (mvn test) takes seconds, and continuous integration (mvn verify) runs everything.

  1. Packaging: maven-jar-plugin, shade and spring-boot-maven-plugin

Three ways to produce a runnable artifact, with different properties.

maven-jar-plugin: the plain jar

<plugin>
    <artifactId>maven-jar-plugin</artifactId>
    <configuration>
        <archive>
            <manifest>
                <mainClass>com.nexussoftware.bibliotech.BiblioTechApplication</mainClass>
            </manifest>
        </archive>
    </configuration>
</plugin>

It produces a jar with only your classes. To run it you have to supply the complete classpath:

java -cp "target/bibliotech.jar:lib/*" com.nexussoftware.bibliotech.BiblioTechApplication

It is what you use to publish libraries, where the consumer resolves the dependencies.

maven-shade-plugin: the uber-jar

<plugin>
    <artifactId>maven-shade-plugin</artifactId>
    <version>3.5.3</version>
    <executions>
        <execution>
            <phase>package</phase>
            <goals><goal>shade</goal></goals>
            <configuration>
                <transformers>
                    <transformer implementation=
                        "org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                        <mainClass>com.nexussoftware.bibliotech.BiblioTechApplication</mainClass>
                    </transformer>
                </transformers>
            </configuration>
        </execution>
    </executions>
</plugin>

It unpacks every dependency and puts them mixed together into a single jar. It works, but it has a known problem: if two jars have files with the same name under META-INF/services —very common—, one overwrites the other and something stops working mysteriously. It is solved with transformers, but you have to know about it.

spring-boot-maven-plugin: the layered jar

It is the one BiblioTech uses. You already saw its structure in 11-02: it keeps every dependency as a separate jar inside BOOT-INF/lib/ and provides its own class loader.

plain jar shade Spring Boot
Dependencies Outside Unpacked and mixed Intact jars inside
Resource collisions — Yes, a real risk No
Runnable with java -jar Only with a classpath Yes Yes
Layers for Docker No No Yes (12-06)
Typical use Libraries Applications without Spring Spring Boot applications

That row about layers matters for 12-06: the Spring Boot jar can be split into layers (dependencies, snapshot dependencies, resources, application classes) so that a Docker image only has to rebuild the layer that changed. Since dependencies change far less than your code, a deployment goes from uploading 50 MB to uploading 200 KB.

exec-maven-plugin

To run any class without Spring Boot:

mvn exec:java -Dexec.mainClass="com.nexussoftware.bibliotech.ImportTool"

Useful for utilities and maintenance scripts inside the project.

  1. Profiles

A profile activates conditional configuration. Not to be confused with the Spring profiles from 11-02: those affect beans at run time; these affect the build.

<profiles>
    <profile>
        <id>fast</id>
        <properties>
            <skipITs>true</skipITs>
        </properties>
    </profile>

    <profile>
        <id>ci</id>
        <activation>
            <property><name>env.CI</name></property>   <!-- if the CI variable exists -->
        </activation>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.jacoco</groupId>
                    <artifactId>jacoco-maven-plugin</artifactId>   <!-- coverage, 12-05 -->
                </plugin>
            </plugins>
        </build>
    </profile>

    <profile>
        <id>production</id>
        <properties>
            <spring.profiles.active>prod</spring.profiles.active>
        </properties>
    </profile>
</profiles>

Activation:

Way Syntax
Explicit mvn package -Pfast
Deactivating mvn package -P!fast
By property <activation><property>...
By environment variable env.CI
By operating system <activation><os><family>windows
By JDK version <activation><jdk>17</jdk>
By default <activeByDefault>true</activeByDefault>

Seeing which profiles are active:

mvn help:active-profiles

A warning: do not put business logic into Maven profiles. If the artifact you build differs depending on the profile, you are no longer testing what you deploy. Modern practice is to build a single artifact and configure it at run time with environment variables (11-02, 12-06).

  1. Multi-module projects

When a project grows, it is split into modules. It is what BiblioTech will need in 12-01.

bibliotech/
├── pom.xml                     <- aggregator parent, packaging: pom
├── bibliotech-domain/
│   ├── pom.xml
│   └── src/main/java/...       <- entities and rules. NO framework dependencies
├── bibliotech-persistence/
│   ├── pom.xml
│   └── src/main/java/...       <- JPA repositories. Depends on domain
├── bibliotech-service/
│   ├── pom.xml
│   └── src/main/java/...       <- logic. Depends on domain and persistence
└── bibliotech-app/
    ├── pom.xml
    └── src/main/java/...       <- @SpringBootApplication. Depends on all of them

The parent POM:

<project>
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
        <relativePath/>
    </parent>

    <groupId>com.nexussoftware</groupId>
    <artifactId>bibliotech-parent</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>          <!-- produces NO jar: it aggregates -->

    <modules>
        <module>bibliotech-domain</module>
        <module>bibliotech-persistence</module>
        <module>bibliotech-service</module>
        <module>bibliotech-app</module>
    </modules>

    <dependencyManagement>
        <dependencies>
            <!-- Versions of the internal modules, centralised -->
            <dependency>
                <groupId>com.nexussoftware</groupId>
                <artifactId>bibliotech-domain</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>com.nexussoftware</groupId>
                <artifactId>bibliotech-persistence</artifactId>
                <version>${project.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

A child module:

<project>
    <parent>
        <groupId>com.nexussoftware</groupId>
        <artifactId>bibliotech-parent</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>bibliotech-persistence</artifactId>   <!-- groupId and version inherited -->

    <dependencies>
        <dependency>
            <groupId>com.nexussoftware</groupId>
            <artifactId>bibliotech-domain</artifactId>   <!-- no version: the parent gives it -->
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
    </dependencies>
</project>

From the root:

mvn clean install                    # builds ALL the modules, in dependency order
mvn -pl bibliotech-service install   # only that module
mvn -pl bibliotech-service -am install    # that module AND the ones it depends on

The real benefit is not organisational, it is architectural: bibliotech-domain cannot use Spring or JPA, because it does not have them on its classpath. Maven enforces the layer separation that in a single-module project depends on the team's discipline. It is a boundary verified by the build, and it is very powerful.

It is developed in 12-01.

  1. settings.xml and credentials

While pom.xml describes the project and goes into git, settings.xml describes your machine and never goes into git.

Locations: ~/.m2/settings.xml (user) and ${maven.home}/conf/settings.xml (global).

<settings>
    <!-- Corporate mirror: everything goes through it -->
    <mirrors>
        <mirror>
            <id>nexus-corporate</id>
            <mirrorOf>*</mirrorOf>
            <url>https://nexus.nexussoftware.com/repository/maven-public/</url>
        </mirror>
    </mirrors>

    <!-- CREDENTIALS: this is why this file does NOT go into the repository -->
    <servers>
        <server>
            <id>nexus-corporate</id>
            <username>${env.NEXUS_USERNAME}</username>
            <password>${env.NEXUS_PASSWORD}</password>
        </server>
    </servers>

    <proxies>
        <proxy>
            <id>company-proxy</id>
            <active>true</active>
            <protocol>http</protocol>
            <host>proxy.nexussoftware.com</host>
            <port>8080</port>
        </proxy>
    </proxies>
</settings>

Security warning. Credentials never go into the pom.xml, because the POM goes into the code repository and stays in its history forever. They go into settings.xml, and preferably read from environment variables as in the example. Maven offers mvn --encrypt-password to encrypt them with a master key, although that is no substitute for a secrets manager.

And a rule that really is absolute: a secret that has been in git is considered compromised forever. Deleting it in a later commit does not remove it from the history. If it happens, the credential must be rotated. Secrets management is covered in 12-07.

  1. The Maven Wrapper

A real problem: you use Maven 3.9.8, a colleague has 3.6.3, the continuous integration server has 3.8.1. Something fails on only one of the three. Diagnosing it costs a day.

The Maven Wrapper solves it: a script in the repository that downloads and uses the exact version of Maven the project declares.

mvn wrapper:wrapper -Dmaven=3.9.8

It generates:

mvnw                                     <- Unix script
mvnw.cmd                                 <- Windows script
.mvn/wrapper/maven-wrapper.properties    <- the declared version

And from then on:

./mvnw clean verify        # instead of mvn clean verify
./mvnw spring-boot:run

Advantages:

  1. Everybody uses the same version, without exception.
  2. Maven does not need to be installed to build the project. Only Java.
  3. The version is in git, so changing it is a reviewable commit.
  4. Continuous integration needs no configuration at all.

These files do go into git (unlike settings.xml). Spring Initializr generates them by default, and it is standard practice today: a modern Java repository builds with ./mvnw verify with nothing installed but a JDK.

  1. Day-to-day commands

Command What it does
mvn clean Deletes target/
mvn compile Compiles the production code
mvn test Compiles and runs the unit tests
mvn package Produces the jar
mvn verify Everything, including the integration tests
mvn install Installs into ~/.m2
mvn clean install Rebuilds from scratch and installs
mvn -DskipTests package Packages without running the tests (it compiles them)
mvn -Dmaven.test.skip=true package Does not even compile them
mvn -o package Offline: only the local repository
mvn -U package Forces snapshots to update
mvn -X package Full debug output
mvn -q package Quiet: errors only
mvn -T 1C package Parallel: 1 thread per core
mvn -pl module -am install One module and its dependencies
mvn dependency:tree The dependency tree
mvn dependency:analyze Declared but unused / used but undeclared
mvn versions:display-dependency-updates What is out of date
mvn versions:display-plugin-updates Out-of-date plugins
mvn help:effective-pom The resulting POM after inheritance
mvn help:active-profiles Active profiles

Three practical notes:

  • -DskipTests versus -Dmaven.test.skip=true: the first compiles the tests but does not run them (it catches compilation errors in them); the second does not even compile them. Prefer the first.
  • -o (offline) is useful on a train or with the network down, if you already have everything in ~/.m2.
  • -T 1C can halve the time of a large multi-module project. It is safe if the plugins you use support parallel execution, and the usual ones do.

And for the periodic update of dependencies:

mvn versions:display-dependency-updates
[INFO] The following dependencies have newer versions:
[INFO]   com.h2database:h2 ................................. 2.2.224 -> 2.3.230
[INFO]   org.springframework.boot:spring-boot-starter ....... 3.3.2 -> 3.3.3

Running it every few weeks and applying the patches is one of the most profitable hygiene practices there is, and the direct answer to the Log4Shell lesson.

  1. Reproducibility and pinning versions

The same git tag must produce the same artifact, today and three years from now. That is reproducibility, and it is what lets you rebuild an old version to debug an incident.

Things that break it:

Practice Why it breaks it Alternative
<version>LATEST</version> It changes over time. Removed in Maven 3 A concrete version
<version>RELEASE</version> Same. Removed A concrete version
<version>[1.0,2.0)</version> (a range) Resolves differently depending on the day A concrete version
-SNAPSHOT dependencies Mutable by definition A released version
Not pinning plugin versions Maven chooses and it can change Pin them (or inherit from the BOM)
Depending on the platform encoding Different per machine project.build.sourceEncoding
Depending on the installed JDK version Different per machine <release>17</release>

That second-to-last point is real and always forgotten: if you do not pin a plugin's version, Maven uses the latest available and your build can change behaviour without you having touched anything. spring-boot-starter-parent pins the usual plugins; for the rest, give them a version.

mvn versions:display-plugin-updates   # detects plugins with no pinned version

Reproducibility is not theoretical. The day there is an incident in version 1.4.2 that was deployed eight months ago, you will have to rebuild it exactly in order to debug it.

  1. Maven versus Gradle

The two dominant tools. An honest comparison:

Aspect Maven Gradle
Configuration Declarative XML A DSL in Groovy or Kotlin (imperative)
Verbosity High (XML) Low
Learning curve Low: fixed conventions Medium-high: you have to learn the DSL
Predictability Very high: fixed life cycle Lower: anyone can write logic
Incremental compilation No Yes
Build cache No Yes, local and remote
Speed on large projects Lower Considerably higher
Background daemon No Yes
Flexibility Limited (plugins) Total (it is code)
Readability by third parties High: all POMs look alike Variable: there may be arbitrary logic
Adoption in companies Majority Growing
Android No The standard
Tooling and documentation Enormous Broad

An example of the same project in both:

<!-- Maven -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
// Gradle (Kotlin DSL)
implementation("org.springframework.boot:spring-boot-starter-data-jpa")

Gradle is obviously more concise. And that is also where its risk lies: because the build file is code, a team can end up with conditional logic, custom tasks and behaviour that has to be read and understood before touching anything. A three-hundred-line pom.xml is boring but you understand it at a glance; a hundred-line build.gradle.kts can do anything.

Why this course uses Maven:

  1. It is the majority in Java enterprise back-end work: it is what you will run into.
  2. It is easier to learn: you do not have to learn a language on top of the tool.
  3. It is predictable: the life cycle is always the same, and that is exactly what you want while you are learning everything else.
  4. Spring Boot's documentation uses Maven in its main examples.

And a professional recommendation: learn Maven first, and Gradle when you need it. The concepts —coordinates, scopes, transitivity, life cycle, plugins— are the same in both. The syntax is what changes.

  1. BiblioTech as a complete Maven project

The result. Final structure:

bibliotech/
├── .gitignore                       <- target/, *.iml, .idea/
├── .mvn/wrapper/
│   └── maven-wrapper.properties
├── mvnw, mvnw.cmd
├── pom.xml
└── src/
    ├── main/
    │   ├── java/com/nexussoftware/bibliotech/
    │   │   ├── BiblioTechApplication.java
    │   │   ├── config/          BiblioTechProperties, BiblioTechConfiguration
    │   │   ├── domain/          Material, Book, Magazine, Dvd, Employee, Loan, Reservation
    │   │   ├── service/         LoanManager, FineCalculator, NoticeService
    │   │   ├── persistence/     MaterialRepository, EmployeeRepository, LoanRepository
    │   │   ├── network/         MetadataClient, CatalogServer
    │   │   ├── infrastructure/  TimingAspect
    │   │   └── presentation/    BiblioTechStartup
    │   └── resources/
    │       ├── application.yml
    │       ├── application-dev.yml
    │       ├── application-prod.yml
    │       └── data.sql
    └── test/
        ├── java/com/nexussoftware/bibliotech/
        │   ├── domain/          LoanTest, MaterialTest, IsbnTest
        │   ├── service/         FineCalculatorTest, LoanManagerTest
        │   ├── persistence/     AtomicWriteTest, LoanRepositoryIT
        │   └── BiblioTechApplicationTests
        └── resources/
            └── application-test.yml

The minimum .gitignore:

target/
!.mvn/wrapper/maven-wrapper.jar
*.iml
.idea/
.classpath
.project
.settings/

And the complete cycle:

# Clean build with all the tests
./mvnw clean verify

# Development
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

# Package and run
./mvnw clean package
java -jar target/bibliotech-1.0.0-SNAPSHOT.jar --spring.profiles.active=prod

# Diagnosis
./mvnw dependency:tree
./mvnw versions:display-dependency-updates

Output of ./mvnw clean verify:

[INFO] --- clean:3.2.0:clean (default-clean) @ bibliotech ---
[INFO] Deleting /home/marta/bibliotech/target
[INFO] --- resources:3.3.1:resources (default-resources) @ bibliotech ---
[INFO] Copying 4 resources from src/main/resources
[INFO] --- compiler:3.13.0:compile (default-compile) @ bibliotech ---
[INFO] Compiling 31 source files with javac [debug release 17]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ bibliotech ---
[INFO] Compiling 9 source files with javac [debug release 17]
[INFO] --- surefire:3.2.5:test (default-test) @ bibliotech ---
[INFO] Tests run: 41, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jar:3.4.1:jar (default-jar) @ bibliotech ---
[INFO] Building jar: /home/marta/bibliotech/target/bibliotech-1.0.0-SNAPSHOT.jar
[INFO] --- spring-boot:3.3.2:repackage (repackage) @ bibliotech ---
[INFO] Replacing main artifact with repackaged archive
[INFO] --- failsafe:3.2.5:integration-test (default) @ bibliotech ---
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- failsafe:3.2.5:verify (default) @ bibliotech ---
[INFO] BUILD SUCCESS
[INFO] Total time: 24.183 s

The whole lesson is in that output: the phases in order, the plugins each one runs, surefire with the 41 unit tests, the jar, Spring Boot's repackage and failsafe with the 3 integration ones.

Compared with the starting point:

Aspect javac by hand (modules 1-10) Maven
Compiling A 15-line command ./mvnw compile
Dependencies Manually downloading 40 jars 6 declarations in the POM
Transitives By hand, one by one Automatic
Conflicts Trial and error A deterministic rule
Tests There was no convenient way ./mvnw test
Packaging jar with a hand-written manifest ./mvnw package
Another machine "It works on mine" ./mvnw verify, and that is it
Changing a version Download, replace, pray One line, verify
Security audit Impossible dependency:tree

That second-to-last row is the complete answer to Log4Shell: one line to change the version, one command to verify that nothing broke.

  1. Common Mistakes and Tips

Mistake: putting a <version> on dependencies the BOM manages. You override tested versions and cause subtle incompatibilities that show up at run time. If you need another one, override the property.

Mistake: using version ranges or LATEST. They break reproducibility. Concrete versions, always.

Mistake: believing that mvn test runs the integration tests. The *IT ones are run by failsafe, in mvn verify. And there is no warning that they were not run.

Mistake: not pinning plugin versions. Maven uses the latest available and your build changes without you touching anything.

Mistake: committing target/ to git. It is all regenerable, it takes up a lot of space and it causes constant conflicts.

Mistake: credentials in the pom.xml. The POM goes into git and stays in the history forever. They go into settings.xml, and a secret that has been in git has to be rotated.

Mistake: mvn clean install for everything. It is slower than necessary and it fills ~/.m2. To verify, mvn verify.

Mistake: -DskipTests in continuous integration. That is exactly where they must run.

Mistake: not declaring a dependency you use directly. If it reaches you transitively and tomorrow the intermediary stops bringing it in, your code stops compiling. mvn dependency:analyze detects it.

Mistake: source/target instead of release. It lets you compile code that uses API missing from the target version, and the failure appears at run time.

Mistake: forgetting project.build.sourceEncoding. Accented characters get corrupted differently depending on the machine.

Tip: always use the Maven Wrapper. It eliminates a whole class of "it works on my machine" problems and requires nothing to be installed.

Tip: learn to read dependency:tree. It is what will get you out of trouble most often, and it is the response tool for vulnerabilities.

Tip: mvn help:effective-pom when you do not understand where something comes from. It shows the POM after inheritance, with everything resolved.

Tip: mvn versions:display-dependency-updates every few weeks. Applying patches regularly is far cheaper than an emergency leap.

Tip: keep *Test and *IT apart. The fast cycle takes seconds; continuous integration runs everything.

Tip: mvn -T 1C on large projects. It can halve the time.

Tip: when something fails inexplicably, mvn -X. The debug output says where every setting and every artifact comes from.

  1. Exercises

Exercise 1: reading and fixing a POM

A colleague at Nexus Software has written this pom.xml for a new microservice. It builds, but it has seven problems. Find them, explain the consequence of each and write the corrected version.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
    </parent>

    <groupId>com.nexussoftware</groupId>
    <artifactId>catalog-service</artifactId>
    <version>1.0.0</version>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
            <version>3.2.0</version>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>LATEST</version>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.10.2</version>
        </dependency>

        <dependency>
            <groupId>com.nexussoftware</groupId>
            <artifactId>bibliotech-common</artifactId>
            <version>2.1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Exercise 2: resolving a dependency conflict

BiblioTech throws this exception at run time, not at compile time:

java.lang.NoSuchMethodError: 'com.fasterxml.jackson.databind.ObjectMapper
com.fasterxml.jackson.databind.json.JsonMapper$Builder.build()'

The dependency tree, trimmed:

com.nexussoftware:bibliotech:jar:1.0.0-SNAPSHOT
+- org.springframework.boot:spring-boot-starter-web:jar:3.3.2:compile
|  \- org.springframework.boot:spring-boot-starter-json:jar:3.3.2:compile
|     \- com.fasterxml.jackson.core:jackson-databind:jar:2.17.2:compile
+- com.example:report-client:jar:4.2.0:compile
|  \- com.fasterxml.jackson.core:jackson-databind:jar:2.9.8:compile
\- com.example:nexus-utils:jar:1.8.0:compile
   \- com.fasterxml.jackson.core:jackson-databind:jar:2.13.0:compile

You are asked for:

  1. Explain why the error appears at run time and not at compile time.
  2. Apply the resolution rule: which version wins, and why?
  3. Give three different solutions with their XML, stating advantages and drawbacks.
  4. Pick one and justify it.
  5. Write the command that confirms the conflict has been resolved.

Exercise 3: separating the tests and adding coverage

BiblioTech has 41 unit tests (11-04) and is about to add integration tests against H2. Right now mvn test takes 6 seconds; with the integration ones it would go up to 45.

Configure the pom.xml so that:

  1. mvn test runs only the unit tests (fast, for development).
  2. mvn verify runs unit and integration tests.
  3. The integration ones are named *IT.java and tagged with @Tag("integration").
  4. There is a ci profile that also generates the coverage report with JaCoCo and fails if instruction coverage drops below 70 %.
  5. There is a fast profile that skips the integration tests even in mvn verify.
  6. Write the commands for each scenario: development, before pushing changes, continuous integration and urgent packaging.

Solutions

Solution 1

The seven problems:

# Problem Consequence
1 <version>3.2.0</version> on spring-boot-starter-data-jpa It overrides the parent's BOM (3.3.2). It mixes Spring Data 3.2 with Spring Framework 6.1.11: subtle incompatibilities at run time
2 <version>LATEST</version> Removed in Maven 3; and even if it worked, it completely breaks reproducibility
3 H2 without <scope>runtime</scope> It ends up on the compile classpath: somebody can couple to H2 by accident, and the test database travels to production
4 junit-jupiter without <scope>test</scope> JUnit ends up inside the production jar: unnecessary weight and attack surface
5 JUnit with an explicit version The BOM already manages it. And besides, spring-boot-starter-test should be used, which brings AssertJ and Mockito
6 A -SNAPSHOT dependency in a project with a released version 1.0.0 Version 1.0.0 is not reproducible: it depends on a mutable artifact. You never release depending on a SNAPSHOT
7 <source>/<target> instead of <release> It lets you compile code using Java 21 API with JDK 21 and generate bytecode 17: NoSuchMethodError at run time. Besides, the parent already configures it with java.version

An eighth, minor one: <relativePath/> is missing from <parent>, which makes Maven look for a POM in ../pom.xml first. It does not fail, but it emits a warning and it can surprise you.

Corrected version:

<?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>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
        <relativePath/>                     <!-- (8) look for it in the repository -->
    </parent>

    <groupId>com.nexussoftware</groupId>
    <artifactId>catalog-service</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>17</java.version>     <!-- (7) the parent applies it to the compiler -->
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- (1) no version: the parent's BOM manages it -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- (2) no version: Jackson is managed by the BOM too -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <!-- (3) runtime: it is not used when compiling -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- (4)(5) the test starter: JUnit + AssertJ + Mockito, test scope -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- (6) a RELEASED version of the internal library -->
        <dependency>
            <groupId>com.nexussoftware</groupId>
            <artifactId>bibliotech-common</artifactId>
            <version>2.1.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

A note on point 6: if bibliotech-common:2.1.0 is not published yet, the correct solution is to publish it first and then release the service. In the meantime, the service must be 1.0.0-SNAPSHOT. It is the rule that a released version can only depend on released versions.

Solution 2

1. Why at run time and not at compile time.

At compile time, Maven uses the resolved version —2.9.8, as point 2 shows— and the compiler only verifies the signatures your code uses. report-client is already compiled: its bytecode contains a call to JsonMapper$Builder.build() that existed in 2.17 but not in 2.9.8.

At run time the JVM tries to link that call against the class on the classpath, does not find it and throws NoSuchMethodError. Linkage errors happen at run time, not at compile time: it is the same mechanism from module 10 when class loading was discussed.

2. Which version wins.

All three are at depth 2 or 3:

Path Depth Version
starter-web → starter-json → jackson-databind 3 2.17.2
report-client → jackson-databind 2 2.9.8
nexus-utils → jackson-databind 2 2.13.0

report-client (2.9.8) and nexus-utils (2.13.0) tie at depth 2. On a tie, the one declared first in the POM wins, and report-client appears earlier. 2.9.8 wins, the oldest of the three.

That is exactly the case that surprises people: the newest does not win, the nearest does, and an old transitive can bring the project down.

3. Three solutions.

A. Declaring the dependency directly (depth 1: it always wins):

<dependencies>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <!-- no version: the one from Spring Boot's BOM (2.17.2) -->
    </dependency>
    ...
</dependencies>

For: simple, explicit, and dependency:analyze stops complaining. Against: you have to remember it is there because of a conflict; a comment is advisable.

B. dependencyManagement (wins at any depth):

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.17.2</version>
        </dependency>
    </dependencies>
</dependencyManagement>

For: it is the tool designed for this, it makes the intent clear and it is the standard way to patch a vulnerable transitive. Against: it pins the version by hand instead of leaving it to the BOM (better: override the <jackson.version> property).

C. exclusions on the offending dependencies:

<dependency>
    <groupId>com.example</groupId>
    <artifactId>report-client</artifactId>
    <version>4.2.0</version>
    <exclusions>
        <exclusion>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<!-- and the same on nexus-utils -->

For: explicit about who was causing the problem. Against: verbose, it has to be repeated on every guilty dependency, and if a third one appears tomorrow you have to remember.

4. Which to choose.

B, with one nuance: instead of pinning 2.17.2 by hand, override the BOM's property.

<properties>
    <!-- We force Jackson's version: report-client:4.2.0 drags in 2.9.8,
         which lacks JsonMapper.Builder.build() (NoSuchMethodError, BIB-207) -->
    <jackson.version>2.17.2</jackson.version>
</properties>

Reasons: dependencyManagement (which is what lies behind that property) is the mechanism designed for this, it wins at any depth, it works even if a fourth dependency appears tomorrow bringing another version, and the comment documents the why for whoever reads it two years from now.

A mandatory precaution: moving report-client from Jackson 2.9.8 to 2.17.2 is a jump of eight minor versions. It might use API that changed. That is why this change must be accompanied by 11-04's tests running green — which is exactly the argument that lesson closed with.

5. Verification:

mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind -Dverbose
[INFO] +- com.example:report-client:jar:4.2.0:compile
[INFO] |  \- (com.fasterxml.jackson.core:jackson-databind:jar:2.9.8:compile
             - omitted for conflict with 2.17.2)

And afterwards, mvn verify to confirm that the 41 tests are still green.

Solution 3

<build>
    <plugins>
        <!-- (1) SUREFIRE: UNIT tests only -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <configuration>
                <!-- Double safety: by name and by tag -->
                <excludes>
                    <exclude>**/*IT.java</exclude>
                </excludes>
                <excludedGroups>integration</excludedGroups>
            </configuration>
        </plugin>

        <!-- (2)(3) FAILSAFE: INTEGRATION tests in verify -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-failsafe-plugin</artifactId>
            <configuration>
                <includes>
                    <include>**/*IT.java</include>
                </includes>
                <groups>integration</groups>
                <skipITs>${skipITs}</skipITs>     <!-- (5) controlled by a profile -->
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>integration-test</goal>
                        <goal>verify</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

<properties>
    <java.version>17</java.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <skipITs>false</skipITs>          <!-- (5) by default they DO run -->
    <jacoco.version>0.8.12</jacoco.version>
</properties>

<profiles>
    <!-- (4) ci PROFILE: coverage with a threshold -->
    <profile>
        <id>ci</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.jacoco</groupId>
                    <artifactId>jacoco-maven-plugin</artifactId>
                    <version>${jacoco.version}</version>
                    <executions>
                        <!-- Instruments before the tests -->
                        <execution>
                            <id>prepare-agent</id>
                            <goals><goal>prepare-agent</goal></goals>
                        </execution>
                        <!-- Generates the HTML report after the tests -->
                        <execution>
                            <id>report</id>
                            <phase>test</phase>
                            <goals><goal>report</goal></goals>
                        </execution>
                        <!-- Checks the threshold and FAILS if it is not met -->
                        <execution>
                            <id>coverage-threshold</id>
                            <phase>verify</phase>
                            <goals><goal>check</goal></goals>
                            <configuration>
                                <rules>
                                    <rule>
                                        <element>BUNDLE</element>
                                        <limits>
                                            <limit>
                                                <counter>INSTRUCTION</counter>
                                                <value>COVEREDRATIO</value>
                                                <minimum>0.70</minimum>
                                            </limit>
                                        </limits>
                                    </rule>
                                </rules>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </profile>

    <!-- (5) fast PROFILE: no integration tests -->
    <profile>
        <id>fast</id>
        <properties>
            <skipITs>true</skipITs>
        </properties>
    </profile>
</profiles>

And the corresponding integration test:

package com.nexussoftware.bibliotech.persistence;

import org.junit.jupiter.api.Tag;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

@DataJpaTest              // (11-04) the persistence layer only
@Tag("integration")       // (3) the tag
class LoanRepositoryIT {  // (3) IT suffix: failsafe picks it up, not surefire

    @Autowired private LoanRepository repository;
    // ...
}

(6) Commands per scenario:

Scenario Command What it runs Time
Development, after every change ./mvnw test Only the 41 unit tests ~6 s
Before pushing changes ./mvnw verify Unit + integration ~45 s
Continuous integration ./mvnw clean verify -Pci Everything + coverage with a threshold ~60 s
Urgent packaging ./mvnw package -Pfast Unit tests only, produces the jar ~10 s
A real emergency ./mvnw package -DskipTests No tests at all. Only for debugging locally ~5 s

Details of the solution worth underlining:

  • A double filter in surefire (by *IT name and by tag). It is redundant on purpose: if somebody forgets the IT suffix but adds the tag —or the other way round—, the test still stays out of the fast cycle. Redundant filters in build configuration are cheap and they prevent surprises.
  • ${skipITs} as a property instead of duplicating the plugin configuration in the profile. The profile only changes one value.
  • JaCoCo only in the ci profile. Instrumenting the bytecode slows the tests down; in development it adds nothing.
  • check in the verify phase. That way the build fails for insufficient coverage after running all the tests, not before.
  • The 70 % threshold is a team decision, not a universal truth. And remember the warning from 11-04: coverage is not quality, and too high a threshold produces bad tests. It is discussed in depth in 12-05.

Conclusion

BiblioTech is now a complete Maven project, reproducible and runnable on any machine in the world that has Java 17.

You understand what problem a build tool solves: not just compiling, but resolving dependency hell —the thirty transitive artifacts a single starter brings in, with versions that have to be compatible with each other—, running tests, packaging and doing all of it reproducibly. And you know why module 1's javac with a hand-written classpath stopped being viable the moment Spring Boot came in.

You know the principle of convention over configuration, which explains why in 11-04 you put the tests in src/test/java and mvn test found them without you configuring anything, and why any Maven project in the world builds the same way with no documentation to read. With its trade-off acknowledged: if your project does not fit the conventions, Maven becomes awkward.

You can read and write a POM: the groupId:artifactId:version coordinates you already saw in 11-01 and how they map to the path in ~/.m2, the packaging, the difference between a mutable SNAPSHOT and an immutable released version —with its professional rule: a released version is never overwritten and never depends on a SNAPSHOT—, and the properties for centralising versions and pinning the encoding.

You have mastered dependencies: the three repositories they are looked up in, the scopes —including the two you were already using without knowing it, runtime for H2 and test for JUnit, which is what keeps a testing framework out of the production jar—, the transitivity that brings in thirty jars from one declaration, and the nearest-definition rule with its consequence that surprises everybody: the newest does not win, the nearest does, and an old transitive can cause a NoSuchMethodError at run time. You know how to diagnose it with dependency:tree —which also answers 11-01's security question: "do I have this library and who pulled it in?"— and with dependency:analyze, which detects the real risk of directly using something you only have transitively.

You can tell dependencyManagement from dependencies and you know their three uses, including the one that matters for security: forcing the fixed version of a vulnerable transitive without waiting for the intermediary to update. And you finally understand spring-boot-starter-parent completely, as a BOM managing more than 400 artifacts, with the warning you had been carrying since 11-02 now justified: putting a <version> on what the BOM manages means overriding versions tested together.

You know the life cycle with its three chains and the key idea that running a phase runs every previous one —which is the answer to why mvn test compiled your code without being asked—, and the second key idea: Maven does nothing by itself, everything is done by plugins hooked to phases, also invocable directly as plugin:goal. You know what BiblioTech's plugins do, why <release>17</release> is better than source/target, and —crucially— the difference between surefire and failsafe, with the trap everybody falls into: mvn test does not run the *IT ones, and it does not warn you.

You know how to package in three ways and why Spring Boot's layered jar is superior to shade's uber-jar, with its consequence for 12-06: rebuilding only the layer that changed turns a 50 MB deployment into a 200 KB one. You handle build profiles —different from Spring's— with the warning not to build different artifacts per environment. And you know about multi-module projects, whose real benefit is not organisational but architectural: bibliotech-domain cannot use Spring because it does not have it on its classpath, and that turns layer separation into a boundary verified by the build.

You know that credentials go into settings.xml, never into the POM, and that a secret that has been in git is considered compromised forever. You use the Maven Wrapper, which eliminates a whole class of "it works on my machine" problems and lets the project be built without installing Maven. You have the day-to-day commands and the reproducibility practices: never ranges, never LATEST, never a SNAPSHOT in production, pinned plugin versions and explicit encoding.

And you have the judgement for the comparison with Gradle: more concise, with caching and incremental builds, considerably faster on large projects; but less predictable, because its build file is code that can do anything. With the practical recommendation: the concepts transfer; learn Maven first.


BiblioTech builds with ./mvnw clean verify in twenty-four seconds: forty-one unit tests, three integration tests, and a fifty-megabyte executable jar that starts on any machine with Java 17. And changing the version of a vulnerable dependency is now one line and one command. The Log4Shell lesson is settled.

But look at those forty-one tests again.

They test FineCalculator, which only needs a Clock. They test Loan's rules, which are plain Java. They test AtomicWrite with a temporary directory. And as soon as a real collaborator appeared —a repository, a notice service—, you had to write by hand an in-memory implementation and another one that recorded calls: twenty lines of inner classes per test, with two simple collaborators. LoanManager has five. LoanRepository is a Spring Data interface with twenty inherited methods: implementing it by hand is unfeasible. And the MetadataClient from 09-06 needs a real HTTP API at the other end.

There are also questions that assertions about the result do not answer. Was the notice sent to the right employee, with the right text? Was the repository called exactly once or forty times? What happens when the database throws an exception —a scenario that is extremely hard to provoke with a real implementation and that in production will happen on any given Tuesday?

The next lesson brings in test doubles by name —dummy, stub, spy, mock and fake— and Mockito 5, which generates them for you with an annotation, using exactly module 10's bytecode generation. You will define behaviour with when(...).thenReturn(...), provoke failures with thenThrow, verify interactions with verify —and learn the criterion for when to verify and when not to, because too much verification produces precisely those brittle tests that break when you refactor—, capture with ArgumentCaptor what was really passed to the notice service, and test the MetadataClient without touching the network.

And you will see something more important than any API: that sometimes the best answer is not a mock, but a design that makes the mock unnecessary. Just as 11-04's fixed Clock did.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved