# Maven, From Zero

> Java's build tool and dependency manager: the POM, the build lifecycle, coordinates and repositories, and why 'it works on Maven Central' is the default.


---

# Maven, From Zero

You inherited a Java project, typed `mvn package`, and a wall of text scrolled past for two minutes before spitting out a `.jar`. Somewhere in there a hundred libraries got downloaded that nobody on your team wrote. The `pom.xml` is 300 lines of XML you're afraid to touch. This guide turns Maven from a magic incantation into a tool you understand, so you know exactly what each command does and why the XML is shaped the way it is.

## How to read this

Read the phases in order the first time. Phase 1 builds the mental model: Maven trades freedom for convention, and once you see the convention, the rest stops being arbitrary. Phase 2 is the day-to-day: editing the POM, adding dependencies, running the lifecycle. Phase 3 is what bites you in production: dependency conflicts, the local repo going stale, and multi-module builds. If you already use Maven and want the gotchas, skim 1 and 2 and slow down on 3.

## The phases

1. [The mental model: convention over configuration](01-convention-over-configuration.md) - what Maven is, why it exists, and the POM as a single source of truth.
2. [The everyday core: POM, coordinates, and the lifecycle](02-pom-coordinates-lifecycle.md) - dependencies, repositories, and the commands you actually run.
3. [Production reality: conflicts, the local repo, and multi-module](03-conflicts-and-multi-module.md) - transitive dependency hell, stale caches, and projects with more than one module.


---

# The mental model: convention over configuration

Before Maven, building a Java project meant writing a script that compiled every source file, copied resources, ran the tests, and zipped the result. Every project did it differently. Open someone else's repo and you had to read their build script line by line to find out where the source code even lived. Maven's founding idea is a reaction to that: stop describing *how* to build, and instead *declare what the project is*. Maven already knows how to build a Java project - as long as your project looks the way it expects.

That trade is the whole story. You give up the freedom to lay out your project however you like. In return, you never write build logic again, and any Maven project is instantly legible to anyone who knows Maven.

## The convention: where things live

When Maven builds your project, it does not ask you where the source code is. It looks in the place it has always looked:

```text
my-app/
├── pom.xml                  ← the project descriptor
├── src/
│   ├── main/
│   │   ├── java/            ← your production code
│   │   └── resources/       ← config files, bundled into the jar
│   └── test/
│       ├── java/            ← your test code
│       └── resources/       ← test-only config
└── target/                  ← build output (compiled classes, the jar) - gitignored
```

*What just happened:* you saw the entire layout you need to memorize. Put `.java` files under `src/main/java` and Maven compiles them. Put tests under `src/test/java` and Maven runs them. The `target/` directory is generated - you never commit it. There is no setting that says "the source is here." The location *is* the setting.

This is the meaning of **convention over configuration**: the default behavior is correct for the common case, so configuration is only needed when you deviate. A standard Java project needs almost no build instructions because almost nothing is unusual about it.

> A useful instinct: if you find yourself writing XML to tell Maven where your source files are, stop. You are fighting the tool. Move the files to where Maven expects them and delete the configuration.

## The POM: one file that describes the project

The other half of Maven is the **POM** - the Project Object Model - written as `pom.xml`. This is the single file that declares what your project is. The smallest meaningful POM looks like this:

```xml
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example</groupId>
  <artifactId>my-app</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>
</project>
```

*What just happened:* you declared an identity, not a procedure. `groupId`, `artifactId`, and `version` together are the project's **coordinates** - its globally unique name (more on these in phase 2). `packaging` says what to produce: a `jar`. Nothing here says "compile then test then zip." Maven knows that part. The POM only states the facts that make this project *this* project.

The POM is declarative on purpose. You describe the desired end state - these are my coordinates, these are my dependencies, this is what I produce - and Maven figures out the steps. Compare that to a shell script, where you spell out every command in order. The declarative style is why two unrelated Maven projects feel the same: they are both facts in the same shape.

## Why "it's on Maven Central" is the default

Here is the part that quietly changed Java forever. Maven didn't only standardize *how you build* - it standardized *how libraries are named and shared*. Because every project has unique coordinates, a library can be published once to a shared server and any project anywhere can pull it in by name.

That shared server is **Maven Central**. It is the default public repository, and it is enormous - effectively the canonical home for open-source Java libraries.

```xml
<dependencies>
  <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.0.0-jre</version>
  </dependency>
</dependencies>
```

*What just happened:* you didn't download anything, didn't find a website, didn't copy a `.jar` into your repo. You wrote three lines naming a library by its coordinates. The next time Maven builds, it fetches Guava from Maven Central and puts it on your classpath. "It's on Maven Central" became shorthand for "you can use it with three lines of XML."

This is why the Java ecosystem leans so hard on Maven coordinates. Even tools that aren't Maven - Gradle, sbt, Bazel - speak the same coordinate language and pull from the same Central repository. The naming convention outgrew the tool that invented it. If you want the broader picture of how source becomes a shippable artifact, see [Build & Release Basics](/guides/build-and-release-basics); if Java itself is still new to you, [Java, From Zero](/guides/java-from-zero) is the place to start.

## For builders

The mental shift that makes Maven click: **the POM is a description, not a script.** Every time you reach to "make Maven do X," first ask whether X is already the convention. Most of the time the answer is yes, and the right move is to write less XML, not more. The teams that fight Maven are the ones trying to make it behave like the custom build script they had before. The teams that love it are the ones who leaned into the convention and stopped thinking about builds at all.

```quiz
[
  {
    "q": "What does \"convention over configuration\" mean in Maven?",
    "choices": [
      "You must configure every build step explicitly in XML",
      "Sensible defaults handle the common case, so you only configure deviations",
      "Conventions are suggestions Maven ignores at build time",
      "Configuration files override the project's source layout"
    ],
    "answer": 1,
    "explain": "Maven assumes a standard layout and build process, so a normal project needs almost no configuration. You only write XML when you depart from the defaults."
  },
  {
    "q": "Where does Maven expect your production Java source files to live?",
    "choices": [
      "src/main/java",
      "src/java",
      "source/main",
      "target/classes"
    ],
    "answer": 0,
    "explain": "The standard layout puts production code in src/main/java and test code in src/test/java. target/ holds generated build output."
  },
  {
    "q": "Why is \"it's on Maven Central\" enough to use a library?",
    "choices": [
      "Maven Central emails you the jar to install manually",
      "Every library has unique coordinates, so naming them in the POM lets Maven fetch them automatically",
      "Maven Central rewrites your source code to include the library",
      "Libraries on Central require no version number"
    ],
    "answer": 1,
    "explain": "Unique coordinates (groupId/artifactId/version) let any project name a published library and have Maven download it from the shared Central repository."
  }
]
```


---

# The everyday core: POM, coordinates, and the lifecycle

Now you live in the tool. Day to day, Maven is three things: coordinates that name everything, dependencies and plugins you declare in the POM, and a lifecycle of phases you trigger from the command line. Get these three solid and the 300-line POM you were afraid of becomes readable - it is the same handful of ideas repeated.

## Coordinates: the address of everything

Every artifact in the Maven world - your project, every library you depend on, every plugin - has the same three-part address:

```text
groupId    : com.fasterxml.jackson.core    ← who publishes it (reverse-domain namespace)
artifactId : jackson-databind              ← the specific project
version    : 2.17.0                         ← which release
```

*What just happened:* you saw the universal naming scheme. Written compactly, coordinates are `groupId:artifactId:version` - so the above is `com.fasterxml.jackson.core:jackson-databind:2.17.0`. The `groupId` is a reverse-domain name to keep namespaces from colliding; the `artifactId` is the project's short name; the `version` pins the exact release. These three values are how Maven finds anything, locally or in a repository.

This matters because coordinates are the *only* identity that exists. There is no "the latest Jackson" floating around - there is `2.17.0` and `2.18.1` and so on, each a distinct, immutable artifact. Pinning versions is not bureaucracy; it is what makes a build reproducible.

## Dependencies: declaring what you need

You add a library by adding its coordinates under `<dependencies>`. The interesting extra knob is **scope**, which controls *when* the dependency is on the classpath:

```xml
<dependencies>
  <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.0</version>
    <!-- no scope = "compile": available everywhere, the default -->
  </dependency>

  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>          <!-- only when compiling and running tests -->
  </dependency>
</dependencies>
```

*What just happened:* you declared two dependencies with different reach. Jackson has the default `compile` scope, so it is available to your production code, your tests, and the packaged jar. JUnit has `test` scope, so it is present while compiling and running tests but is *not* bundled into your application or available to production code. Scope is how you keep your test framework out of your shipped artifact. The two scopes you will use constantly are the implicit `compile` and the explicit `test`.

## The build lifecycle: phases in a fixed order

This is the idea that unlocks the command line. Maven defines a **lifecycle**: an ordered sequence of **phases**. When you run a phase, Maven runs *every phase before it too*, in order. The default lifecycle, in the order that matters:

```text
validate  →  compile  →  test  →  package  →  verify  →  install  →  deploy
```

*What just happened:* you saw the spine of every Maven build. `validate` checks the project is sane. `compile` compiles `src/main/java`. `test` runs unit tests. `package` bundles compiled code into a `jar` (or `war`). `verify` runs integration checks. `install` copies the artifact into your **local repository** so other projects on your machine can use it. `deploy` uploads it to a remote repository for the whole team. Each phase is a checkpoint, and they always run front to back.

The consequence trips up newcomers, so say it plainly: you never run `compile` and `test` and `package` separately. You run the *last* phase you want, and Maven runs everything up to and including it.

```console
$ mvn package
[INFO] --- compiler:compile (default-compile) ---
[INFO] --- surefire:test (default-test) ---
[INFO]  T E S T S
[INFO]  Tests run: 14, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jar:jar (default-jar) ---
[INFO] Building jar: /home/you/my-app/target/my-app-1.0.0.jar
[INFO] BUILD SUCCESS
```

*What just happened:* one command, `mvn package`, ran four phases. It validated, compiled, ran all 14 tests, and only then built the jar. If a test had failed, the build would have stopped before `package` - you would get no jar, by design. The jar lands in `target/`, named from your coordinates: `my-app-1.0.0.jar`.

> A clean rebuild is `mvn clean package`. `clean` is a different lifecycle whose job is to delete `target/`. Chaining it guarantees no stale class files from a previous build sneak into the new jar.

## install vs deploy: the most common confusion

These two sound alike and do very different things:

```text
mvn install   →  puts your jar in the LOCAL repo (~/.m2/repository on your machine)
mvn deploy    →  uploads your jar to a REMOTE repo (shared, e.g. a company Nexus/Artifactory)
```

*What just happened:* `install` is local and offline - it makes your artifact available to other Maven projects *on your own machine*, which is exactly what you want when project B depends on a library you are actively developing in project A. `deploy` is the publish step - it pushes to a server so other people and CI can pull your artifact. You run `install` constantly during local development; you run `deploy` rarely, usually only from a release pipeline. For where `deploy` fits in shipping software, see [Build & Release Basics](/guides/build-and-release-basics).

## Plugins: where the actual work happens

One more layer to make the POM fully readable. Maven's core does almost nothing on its own - every phase is implemented by a **plugin** bound to it. Compiling is the `maven-compiler-plugin`; running tests is `maven-surefire-plugin`; building the jar is `maven-jar-plugin`. You mostly never name these because the defaults are wired in. You configure a plugin only to override a default - for example, to set the Java version you compile against:

```xml
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <configuration>
        <release>21</release>     <!-- compile for Java 21 -->
      </configuration>
    </plugin>
  </plugins>
</build>
```

*What just happened:* you reached into one phase - compilation - and changed one setting, the target Java version, without touching anything else. That is the normal shape of POM customization: find the plugin behind the phase, configure the one knob you need, leave the rest of the convention alone. When you read a long POM, most of its bulk is exactly this - plugins with a few overrides each.

## In the wild

A real project's `pom.xml` looks long, but skim it and you will see only these pieces: coordinates at the top, a `<dependencies>` block with scopes, and a `<build>` block of plugins with small `<configuration>` overrides. The fear comes from the volume of XML, not from complexity. Once you can name each section - *that is coordinates, that is a test-scoped dependency, that is the compiler plugin set to Java 21* - the file goes quiet.

```quiz
[
  {
    "q": "You run `mvn package`. Which phases execute?",
    "choices": [
      "Only package, in isolation",
      "package and deploy",
      "validate, compile, test, then package - every phase up to and including package",
      "compile and package, but tests are skipped by default"
    ],
    "answer": 2,
    "explain": "Running a phase runs every preceding phase in order. mvn package therefore validates, compiles, runs tests, and only then packages."
  },
  {
    "q": "What is the difference between `mvn install` and `mvn deploy`?",
    "choices": [
      "install compiles, deploy only copies files",
      "install puts the artifact in your local repo; deploy uploads it to a remote shared repo",
      "They are aliases for the same operation",
      "deploy is local, install is remote"
    ],
    "answer": 1,
    "explain": "install writes to ~/.m2 on your machine for local reuse; deploy publishes to a remote repository for the whole team and CI."
  },
  {
    "q": "What does giving a dependency `<scope>test</scope>` accomplish?",
    "choices": [
      "It makes the dependency available everywhere including the shipped jar",
      "It downloads the dependency only when tests fail",
      "It limits the dependency to compiling and running tests, keeping it out of the production artifact",
      "It marks the dependency as optional and ignores it"
    ],
    "answer": 2,
    "explain": "test scope keeps a dependency (like JUnit) on the classpath for tests only, so it is not bundled into or available to production code."
  }
]
```


---

# Production reality: conflicts, the local repo, and multi-module

Everything in phase 2 works on a clean, small project. The trouble starts when your dependencies have dependencies, when the local repo holds a stale or corrupt jar, and when one project grows into several that depend on each other. None of these are exotic - you will hit all three. Here is what is actually happening and how to get unstuck without flailing.

## Transitive dependencies: the libraries you didn't ask for

When you depend on a library, you also get *its* dependencies, and theirs, all the way down. These are **transitive dependencies**, and they are the reason `mvn package` downloads a hundred jars when you named three. This is mostly a gift - you would never want to hand-list every dependency of every dependency. The problem is when two of your dependencies need *different versions* of the same third library.

```console
$ mvn dependency:tree
[INFO] com.example:my-app:jar:1.0.0
[INFO] +- com.lib:alpha:jar:2.0.0:compile
[INFO] |  \- com.shared:util:jar:1.4.0:compile
[INFO] \- com.lib:beta:jar:3.0.0:compile
[INFO]    \- (com.shared:util:jar:1.2.0:compile - omitted for conflict with 1.4.0)
```

*What just happened:* `dependency:tree` printed the full graph of what you actually pull in. Both `alpha` and `beta` need `com.shared:util`, but at different versions - `1.4.0` and `1.2.0`. Maven cannot put both on the classpath, so it picks one. The line `omitted for conflict with 1.4.0` tells you Maven kept `1.4.0` and dropped `1.2.0`. This is the single most useful Maven command for debugging; reach for it first whenever a dependency behaves strangely.

## How Maven picks a winner: nearest wins

When versions conflict, Maven uses **nearest-wins** (more formally, *dependency mediation*): the version closest to your project in the tree is the one selected. "Closest" means fewest hops from your POM. A direct dependency you declare yourself is at depth one and beats anything transitive.

This is also the cause of the classic production failure: your code compiles fine, then at runtime throws `NoSuchMethodError` or `ClassNotFoundException`. What happened is Maven resolved a version of a shared library that one of your dependencies wasn't built against - the method exists in the version that library expected, but not in the version that won. The fix is to take control:

```xml
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.shared</groupId>
      <artifactId>util</artifactId>
      <version>1.4.0</version>     <!-- force this version everywhere -->
    </dependency>
  </dependencies>
</dependencyManagement>
```

*What just happened:* you declared a version in `<dependencyManagement>`, which is a *policy*, not a dependency. It says "whenever anything in this project resolves `com.shared:util`, use `1.4.0`." It does not add the library to your classpath - it only pins the version when something else pulls it in. This is the clean way to end a version war: decide the winner explicitly instead of letting nearest-wins decide for you.

> When you only want to *remove* one bad transitive dependency rather than pin a version, add an `<exclusion>` inside the dependency that drags it in. Use exclusions surgically - each one is a small claim that you know better than the library author about what it needs.

## The local repository: powerful, occasionally treacherous

Every artifact Maven downloads or installs lands in your **local repository**, by default `~/.m2/repository`. On the next build, Maven finds the jar there and skips the download. That cache is why your second build is fast and why CI machines warm it up. Most of the time it is invisible and helpful.

It betrays you in two specific ways. First, a download can get interrupted and leave a corrupt or partial jar, and Maven will keep trusting it - you get baffling errors that survive every rebuild. Second, after a network blip you may see the dreaded `Could not resolve dependencies`, where Maven has cached the *failure* and refuses to retry.

```console
$ mvn package
[ERROR] Failed to execute goal ... Could not resolve dependencies for project com.example:my-app:jar:1.0.0:
[ERROR] Failure to find com.shared:util:jar:1.4.0 in https://repo.maven.apache.org/maven2
[ERROR] was cached in the local repository, resolution will not be reattempted until the update interval has elapsed

$ mvn package -U
[INFO] Downloading from central: https://repo.maven.apache.org/maven2/com/shared/util/1.4.0/util-1.4.0.jar
[INFO] BUILD SUCCESS
```

*What just happened:* the first build failed because Maven had cached an earlier failure to find the artifact. The `-U` flag (`--update-snapshots`) forces Maven to re-check remote repositories instead of trusting the cached miss, and the build recovered. When a build fails with "cached in the local repository," `-U` is the first thing to try. For a genuinely corrupt jar, delete that artifact's folder under `~/.m2/repository` and rebuild - Maven re-downloads a clean copy.

## Multi-module: one build, many parts

Eventually one project becomes several - a `core` library, a `web` app, a `cli` - that all build together and depend on each other. Maven handles this with a **parent POM** that lists child **modules**. The parent's packaging is `pom` (it produces no jar of its own; it exists to coordinate):

```xml
<!-- parent pom.xml -->
<project>
  <groupId>com.example</groupId>
  <artifactId>my-app-parent</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>      <!-- a coordinator, not a jar -->

  <modules>
    <module>core</module>          <!-- each is a subdirectory with its own pom.xml -->
    <module>web</module>
    <module>cli</module>
  </modules>
</project>
```

*What just happened:* you declared an aggregator. `packaging` is `pom` because the parent builds nothing itself - its job is to tie the modules together. Each `<module>` names a subdirectory holding its own `pom.xml`. Running `mvn package` from the parent builds all three modules in the right order: Maven reads the dependency graph between them and builds `core` before `web` if `web` depends on `core`. This ordering - the **reactor** - is automatic; you never sequence the modules by hand.

The payoff is shared configuration and coordinated builds. Children declare the parent and inherit its `dependencyManagement`, its plugin versions, and its properties, so you set the Java version once in the parent and every module obeys. Within the build, modules refer to each other by coordinates exactly like any external dependency - `web` depending on `core` is the same `<dependency>` block you would write for a library from Central, because to Maven there is no difference. That uniformity is the whole point: your own modules and the rest of the world play by one set of rules.

## In the wild

The Maven skills that separate a fluent user from a stuck one are diagnostic, not declarative. `mvn dependency:tree` to see what you really depend on; `-U` to break a stale-cache deadlock; deleting an artifact's folder under `~/.m2` to clear a corrupt jar; reading the reactor order to understand a multi-module build. The POM tells Maven what you want - these tools tell *you* what Maven actually did, which is exactly what you need when the gap between the two is causing the bug.

```quiz
[
  {
    "q": "Two of your dependencies pull in different versions of the same library. How does Maven decide which version to use?",
    "choices": [
      "It always uses the highest version number",
      "It fails the build and asks you to choose",
      "Nearest-wins: the version closest to your project in the dependency tree",
      "It includes both versions on the classpath"
    ],
    "answer": 2,
    "explain": "Maven uses dependency mediation - nearest-wins. The version with the fewest hops from your POM is selected; a direct dependency always beats a transitive one."
  },
  {
    "q": "A build fails with \"was cached in the local repository, resolution will not be reattempted.\" What is the quickest fix to try?",
    "choices": [
      "Run with -U to force Maven to re-check remote repositories",
      "Delete the entire ~/.m2 directory before every build",
      "Add the dependency twice in the POM",
      "Switch the dependency to test scope"
    ],
    "answer": 0,
    "explain": "Maven cached the earlier failure. The -U (--update-snapshots) flag forces a re-check of remote repositories instead of trusting the cached miss."
  },
  {
    "q": "In a multi-module project, what packaging does the parent POM use, and why?",
    "choices": [
      "jar, because it bundles all modules into one archive",
      "pom, because it is a coordinator that produces no artifact of its own",
      "war, because multi-module always means web apps",
      "It needs no packaging element"
    ],
    "answer": 1,
    "explain": "The parent uses packaging 'pom' - it builds nothing itself and exists to list modules and share configuration. Maven's reactor then builds the modules in dependency order."
  }
]
```
