Maven from Scratch: Coordinates, Dependency Mediation and Build Lifecycles
Maven does exactly one job: you write down what you need in a shopping list (pom.xml), and it fetches, processes and packs accordingly. You never need to know which corner of the disk a jar lives in, nor hand-assemble a compile command — you just write a coordinate (organization + module + version) and let it handle the rest. This article covers four things: who you are (coordinates) → what you need (dependencies and scopes) → where to fetch (repositories and mirrors) → how to process (lifecycles and multi-module builds).
Maven is a parcel sorting centre. The address you type when ordering is the coordinate (groupId:artifactId:version); the sorter scans the label and knows which conveyor belt (which lifecycle phase) the parcel belongs on, without any idea which floor your living room is on. And scope is the sticker on the box — "open for inspection only" / "must be signed for" / "do not deliver to this address". Stick it wrong and the parcel shows up in the wrong place, or simply never arrives.

After this article you should be able to answer:
- Why does one
pom.xmlrebuild the project on another machine, while a hand-copiedjavac -cpline cannot? - If two paths drag in two versions of the same library, which one wins — and can I overrule it?
- What does each of the five words in
mvn clean package -DskipTestsactually trigger? Is test code still compiled?
Start with the world before build tools. You write three classes and compile them with one line:
javac -d out src/main/java/com/example/OrderService.javajavac follows the imports and compiles the classes it depends on. But a real project has two hundred source files and a dozen third-party libraries, and the command balloons into this:
javac -d out \ -cp "lib/spring-core.jar:lib/spring-context.jar:lib/jackson-databind.jar:lib/mysql-connector-j.jar" \ $(find src/main/java -name "*.java")- Every new dependency means hand-adding another jar path to
-cp; miss one and the build fails - Dependencies have their own dependencies (transitive ones), and completing that list by memory is nearly impossible
- Move to another machine and the paths and versions all change — what blocks you is the environment, never the code
And that is only "compile". "Run the tests", "build the jar", "deploy to the server" each repeat this manual work. This is exactly what a build tool fixes: turning resolve, compile, test, package and deploy into one repeatable, declarative, machine-independent pipeline.
Maven is the most classic and most widely used tool on that pipeline. The fundamental difference from raw javac is what it trusts:
- Raw javac trusts a local path —
lib/xxx.jarmust physically exist on disk - Maven trusts a coordinate — you declare
org.springframework:spring-core:6.1.0and it fetches the file from a repository itself - So moving machines needs only one
pom.xml, with no dependence on anyone's local setup

That diagram is the skeleton of this article: top-left is the coordinate (GAV) used to identify one artifact; bottom-left is the repository system that decides where to fetch it; the two columns on the right are lifecycles and plugins, which decide what happens to it afterwards. Every section below just zooms into one of those boxes.
how Maven looks for a jar is exactly how you look for a book at university — main library first. You check your own shelf (the local repository ~/.m2/repository); if it is not there you ask the campus loan desk (a private Nexus repo); only then do you go to the national library (Maven Central). Each level keeps a photocopy once found, so borrowing the same book a second time takes almost no time at all.
remember Maven as two things — a "dependency butler" plus a "standardized pipeline". And pom.xml is its spec sheet, declaratively describing what the project needs and produces.
Maven itself is a zip: unzip it and add bin to PATH; it also needs a JAVA_HOME pointing at a JDK:
# Windows: unzip to D:\dev\apache-maven-3.9.6, then set the environment variablesMAVEN_HOME = D:\dev\apache-maven-3.9.6PATH += %MAVEN_HOME%\binmvn -v# Apache Maven 3.9.6 ... Java version: 17.0.10 ... OS name: "windows 11"mvn -v prints the Maven version, the JDK it actually uses, and the OS — the first thing to check when "Maven is using the wrong JDK".
By default Maven downloads from the overseas central repository (repo.maven.apache.org), which is often slow enough to time out inside China. The fix is a mirror. Here is the point beginners confuse most: settings.xml has two locations, with completely different scopes.
| Location | Scope | Follows the machine? | Recommendation |
|---|---|---|---|
MAVEN_HOME/conf/settings.xml | All users | No (lost on reinstall) | Read it for defaults, don't edit |
~/.m2/settings.xml (user level) | Current user | Yes (follows the person) | Edit this one |
Here is a complete user-level ~/.m2/settings.xml, ready to copy:
<?xml version="1.0" encoding="UTF-8"?><settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd"> <!-- 1. Local repository: where downloaded dependencies are stored --> <localRepository>D:/dev/.m2/repository</localRepository> <mirrors> <!-- 2. Aliyun mirror: mirrors the central repo for much faster downloads --> <mirror> <id>aliyun-central</id> <name>Aliyun Central</name> <url>https://maven.aliyun.com/repository/public</url> <mirrorOf>central</mirrorOf> </mirror> </mirrors> <profiles> <profile> <id>jdk-17</id> <activation> <activeByDefault>true</activeByDefault> </activation> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> </profile> </profiles></settings><localRepository>: tells Maven where to cache dependencies,~/.m2/repositoryby default; a separate drive is easier to back up<mirror>: rewrites requests forcentralto the Aliyun URL;mirrorOfis the matching rulemirrorOfascentralmirrors only the central repo; asit mirrors every repo (including your private one) — *never usewhen a private repo is involved*
Trap: setting mirrorOf to hijacks your company's private repository requests to Aliyun, so internal artifacts can never be resolved and you get Could not find artifact com.yourcorp:xxx. The correct setup puts the private repo under <repositories> and mirrors only central, or writes ,!your-private-repo.
Once downloaded, dependencies are cached locally, so builds work offline. Three switches are worth memorizing:
-o(offline): force offline, using only the local repo — good for air-gapped machines or checking cache completeness-U(update-snapshots): force a check for SNAPSHOT updates, essential for team work-X(debug): print full debug logs; turn it on and you see exactly which URL Maven is requesting
when a dependency "was just published but cannot be resolved", try mvn -U clean package first. SNAPSHOT versions refresh only once a day by default, and -U forces an immediate pull.
That "once a day" is the only number here you can actually drag, and it is the most common invisible time lag in a team. Treat it as a knob: on the left you change the repository's <updatePolicy> (expressed in minutes), on the right you get the symptoms that setting produces in a real project:
- The first build of the day compares timestamps, the rest of the day reads the cache
- Most cases of 'published yesterday, still not resolving today' stop right here
- This is Maven's factory setting and a reasonable choice for most single-module projects
- When debugging, ask first: what time did my cache last refresh?
-U and <updatePolicy> are two entrances to the same thing — the flag applies to one build, the setting lives in settings.xml or the repository definition and applies forever. The mvn -U clean deploy you see in CI scripts is exactly a temporary override of daily.
Every artifact in Maven is uniquely located by a coordinate set, GAV for short:
<dependency> <groupId>org.springframework</groupId> <!-- organization / namespace --> <artifactId>spring-context</artifactId> <!-- module name --> <version>6.1.0</version> <!-- version --> <packaging>jar</packaging> <!-- packaging (optional, jar by default) --></dependency>groupId: usually a reversed domain name, e.g.org.springframeworkartifactId: the module name, e.g.spring-contextversion: the version string; a trailingSNAPSHOTmeans work in progress- Together they map to a path in the repository:
org/springframework/spring-context/6.1.0/spring-context-6.1.0.jar
| Repository | Location | Role | Maintained by |
|---|---|---|---|
| Local | Disk, ~/.m2/repository | Caches downloaded dependencies; checked first during a build | You (Maven fills it) |
| Central | Public repo.maven.apache.org | The single official source for open-source libraries | The Maven community |
| Private | Company intranet (Nexus / Artifactory) | Proxies the public repo and hosts internal artifacts | Company ops |
Resolution order is: local repository → private repo (if configured) → central. Each hit back-fills the cache layer by layer: the private repo caches the public artifact, then your local repo caches the private one. The second build barely touches the network.
a private repo exists for two reasons — speed (an intranet proxy of the public repo, sharing one cache across the team) and isolation (internal artifacts never leave the intranet, external ones stay auditable). That is the whole answer to "why a private repo?" in an interview.
The easiest field to misuse, and the one that plants the nastiest mines, is scope. It determines whether a dependency is visible when compiling main code, compiling tests, and at runtime.
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> <scope>provided</scope> <!-- compile-time only, never packaged --></dependency>Here is the full six-scope table — worth bookmarking:
| scope | Main code | Tests | Packaged at runtime | Typical use |
|---|---|---|---|---|
compile (default) | yes | yes | yes | Spring, Jackson, ordinary deps |
provided | yes | yes | no | servlet-api, lombok (provided by container/JDK) |
runtime | no | yes | yes | JDBC drivers (no impl needed at compile time) |
test | no | yes | no | JUnit, Mockito |
system | yes | yes | no | local-path jar (deprecated, avoid) |
import | — | — | — | only for importing a BOM in dependencyManagement |
This sandbox links all three dimensions live — switch the scope to see the difference:
Main code can use it: yesTest code can use it: yesPackaged at runtime: yes, into the final artifact
setting JUnit's scope to compile (or omitting it) ships your test framework into production; setting a JDBC driver to provided makes production throw ClassNotFoundException: com.mysql.cj.jdbc.Driver. Get scope wrong and it works 100% locally and breaks 100% in production.
That table has six rows, and the one thing everybody forgets while copying a pom is the scope itself. Do not copy — tick it out. Every checkbox in this generator emits the scope its dependency actually belongs to, and explains below why that one:
<?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.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>What you declare directly is a direct dependency; what it declares gets passed on to you as a transitive dependency. The trouble is that several paths can pull in different versions of the same library, and Maven must pick one — this is dependency mediation.

students with the same name must state their full ID. A class has three people called "张伟", so a roll call only works with "college - class - student number". Maven's groupId:artifactId:version is exactly that ID system. The trouble: when the same "college + name" (the same groupId:artifactId) shows up with two different numbers (1.0 and 1.1), the JVM only recognises the college-and-name part — so only one may stay. Which one, is the rule below.
The "only one may stay" clause bites hardest when both candidates sit at the same depth. Switch to "version conflict" and you can swap the order in which two dependencies are written in pom.xml and watch the winner flip — which is why "I added a dependency, touched no code, and everything broke":
For example, the project declares A and C:
my-app├── A:1.0│ └── commons:1.0 ← path: my-app → A → commons (depth 2)└── C:1.0 └── B:1.0 └── commons:1.1 ← path: my-app → C → B → commons (depth 3)commonsappears twice: 1.0 and 1.1- Rule one, "nearest wins": 1.0 is at depth 2 versus 1.1's depth 3, so 1.0 wins
- If both paths have the same depth, rule two kicks in, "first declared wins": the dependency written earlier in
pom.xmlwins
That is the entire heart of Maven mediation. Understand it and you can predict the result of mvn dependency:tree before running it.
To knock a transitive version out, use <exclusions>:
<dependency> <groupId>com.example</groupId> <artifactId>C</artifactId> <version>1.0</version> <exclusions> <!-- exclude the commons that C brings in; use our own declared version --> <exclusion> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> </exclusion> </exclusions></dependency>- An
<exclusion>lists onlygroupIdandartifactId, never a version (its meaning is "drop this whole subtree") - After excluding, declare the version you want directly, so that "my version is the one that counts"
Conversely, a library author who wants to stop one of its dependencies from propagating uses <optional>true</optional>:
<dependency> <groupId>com.example</groupId> <artifactId>redis-client</artifactId> <version>2.0</version> <optional>true</optional> <!-- stays here, does not propagate downstream --></dependency>Note: optional is the library author's tool; exclusions is the consumer's tool. The former lives in the library's pom, the latter in your project's pom — don't mix them up.
Pick the wrong version and the code usually blows up at runtime, not compile time. Two commands are the main tools:
# 1. Print the full dependency tree to find who dragged in the stale versionmvn dependency:tree# 2. Report declared-but-unused / overridden deps with cleanup suggestionsmvn dependency:analyzeA typical dependency:tree output, where indentation is path depth:
[INFO] com.example:my-app:jar:1.0-SNAPSHOT[INFO] +- org.springframework:spring-webmvc:jar:6.1.0:compile[INFO] \- com.fasterxml.jackson.core:jackson-databind:jar:2.16.0:compile[INFO] \- com.fasterxml.jackson.core:jackson-core:jar:2.15.0:compile[INFO] (version managed from 2.16.0) # ← downgraded by some dependencyManagementHere is a real NoSuchMethodError: you call a writeValueAsBytes overload that only Jackson 2.16 has, but the runtime classpath actually holds 2.15:
java.lang.NoSuchMethodError: 'byte[] com.fasterxml.jackson.databind.ObjectMapper.writeValueAsBytes(java.lang.Object)' at com.example.api.OrderController.toJson(OrderController.java:42)NoSuchMethodError/NoSuchMethodExceptionare almost always "compiled against A, ran against B"- The first move is always
mvn dependency:tree -Dincludes=com.fasterxml.jackson.coreto see which version finally won - The right way to pin versions is to declare them centrally in the parent pom's
<dependencyManagement>(see Section 8)
Trap: staring at the class name will not find the root cause — NoSuchMethodError means "this class has no such method", and a missing method usually means a version mismatch, not "a coding mistake". Before touching code, confirm the version with dependency:tree.
"Confirm the version first" is far too easy to turn into a slogan. Spread that dependency tree out as a single-step run: on the left, the candidates Maven collects; on the right, the depth table as it fills in and who brought whom. Step through it and watch step 4 — Maven picks the closest, not the newest:
<dependency>A:1.0</dependency> # your pom has exactly two direct dependencies<dependency>C:1.0</dependency> # commons is never mentionedread A's pom → finds commons:1.0 # depth 2: my-app → A → commonsread C's pom → finds B → commons:1.1 # depth 3: my-app → C → B → commonsmediation: one survivor per groupId:artifactIdthe compile classpath gets commons:1.0; 1.1 is never even downloaded| direct dependencies | 2 |
| commons declared | nowhere |
| candidates collected | 0 |
read pom.xmlMaven has three independent lifecycles, each made of ordered phases; running a later phase automatically runs the earlier ones:
| Lifecycle | Main phases (in order) | Purpose |
|---|---|---|
| clean | pre-clean → clean → post-clean | Remove the previous build output |
| default | validate → compile → test → package → verify → install → deploy | Compile, test, package, install, deploy |
| site | pre-site → site → post-site → site-deploy | Generate the project documentation site |
A phase itself is an empty shell; the work is done by plugin goals bound to it — Maven's single most important design:
| Phase | Default bound goal | Output |
|---|---|---|
| compile | maven-compiler-plugin:compile | .class files under target/classes |
| test | maven-surefire-plugin:test | test reports |
| package | maven-jar-plugin:jar | target/*.jar |
| install | maven-install-plugin:install | installed into the local repo |
| deploy | maven-deploy-plugin:deploy | pushed to the private/remote repo |

Those two tables add up to eleven rows, but reading and knowing are different things: you have to click. This diagram splits one mvn package into five boxes — each one says who is doing the work and what breaks if you skip it:
Those two facts — "a later phase drags the earlier ones" and "calling a goal directly skips steps" — are the parts Maven interviews get wrong most often. This lab is built for exactly that; cycle through the four modes in order:
A common command is really a combination of phases; unpack it one flag at a time:
mvn clean package -DskipTests -U -Xclean: run the clean lifecycle first, deletingtargetpackage: run the default lifecycle up to package, producing the jar/war-DskipTests: skip test execution (test code is still compiled);-Dmaven.test.skip=trueskips compiling test code too-U: force snapshot updates;-X: print debug logs
Tip: -DskipTests and -Dmaven.test.skip=true are often used interchangeably, but the former compiles without running, the latter neither compiles nor runs. On CI, pick the latter to save time and the former to keep compile verification.
As a project grows, a single pom becomes thousands of lines. Maven splits it with two orthogonal ideas: aggregation builds everything together, inheritance shares configuration.
spring-demo/ ← parent POM (a parent pom always uses packaging=pom)├── pom.xml├── demo-common/ ← shared module│ └── pom.xml├── demo-service/ ← business module, depends on common│ └── pom.xml└── demo-web/ ← web module, depends on service └── pom.xmlThe parent POM does both aggregation and inheritance:
<project> <groupId>com.example</groupId> <artifactId>spring-demo</artifactId> <version>1.0.0</version> <packaging>pom</packaging> <!-- a parent POM must be pom, never jar --> <!-- Aggregation: list the modules; mvn install builds them in dependency order --> <modules> <module>demo-common</module> <module>demo-service</module> <module>demo-web</module> </modules> <!-- Inheritance: unify versions here; children declare only groupId and artifactId --> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>3.2.0</version> <type>pom</type> <scope>import</scope> <!-- import pulls in a BOM --> </dependency> </dependencies> </dependencyManagement></project>A child module simply inherits the parent and declares its dependencies (versions come from the parent):
<project> <parent> <groupId>com.example</groupId> <artifactId>spring-demo</artifactId> <version>1.0.0</version> </parent> <artifactId>demo-service</artifactId> <!-- groupId / version inherited --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <!-- no version: decided by the parent's dependencyManagement --> </dependency> </dependencies></project><modules>is aggregation: onemvn installbuilds every module, and Maven topologically sorts them for you<dependencyManagement>is inheritance: it manages versions only and does not actually pull anything in; children omit the versionscope=importwithtype=pom: lifts an entire BOM (such as Spring Boot's dependency list) into your version-management center
Key point: the difference between dependencyManagement and dependencies is a frequent interview question — the former is a "price list" that only takes effect when a child declares the dependency; the latter is "placing the order" and pulls the dependency immediately.
Aggregation is often mistaken for "build in directory order"; in reality Maven sorts by the dependency graph. This animation splits one mvn install into six frames — look at frames 5 and 6: downstream modules read what was just installed into the local repository, so "I changed common but web still behaves the old way" is never cache superstition. It simply never read your new class:

| Symptom | Root cause | Fix |
|---|---|---|
Could not resolve dependencies or download timeouts | Slow network or no mirror configured | Configure the Aliyun mirror; retry with mvn -U |
Could not find artifact com.corp:xxx | mirrorOf set to *, hijacking the private repo | Mirror only central; configure the private repo under <repositories> |
NoSuchMethodError / NoClassDefFoundError | Mediation picked a different version | Locate with mvn dependency:tree; pin with dependencyManagement |
Runtime ClassNotFoundException: driver class | JDBC driver scope set to provided/test | Change to runtime (or default compile) |
| Test framework shipped to production | JUnit missing test scope | Add <scope>test</scope> explicitly |
Works locally, CI reports JAVA_HOME invalid | Different JDKs across machines | Pin with toolchains or maven.compiler.release |
SNAPSHOT changes not picked up | Snapshots refresh once a day by default | Force a refresh with mvn -U |
| Code changed but old behavior persists | Stale target not cleaned | Run mvn clean before building |
| Downstream misses a module change | Not installed into the local repo | Run mvn clean install at the root |
duplicate dependency warning | The same module declared twice | Merge duplicates; clean up with dependency:analyze |
sorting errors into two buckets is far more efficient — "cannot fetch" (network, mirror, private repo) and "got it wrong" (mediation, scope, stale cache). The first points you to settings.xml, the second to dependency:tree.
Section 7 listed the three lifecycles, but "phases" stay abstract. This animation walks one dependency from being written into pom.xml all the way to sitting on the runtime classpath. Watch step 4 (mediation) and step 6 (scope decides whether it ships) — they correspond to Sections 5 and 4:

The second lab here answers "what actually changes when I edit a scope". Press the four buttons and watch the same jar's visibility across "compiling main code / compiling tests / packaging", including why a JDBC driver marked provided looks perfectly fine locally and collapses the moment it reaches production:
The third lab answers a question every beginner asks: why does the plain jar produced by mvn package refuse to run with java -jar. A fatjar puts dependencies under BOOT-INF/lib and reads them with a custom class loader — which ties straight back to the classpath lesson in article #1:
The fourth lab goes back to the two most treacherous rules of Section 7. Calling a goal directly skips every earlier phase (so you package stale classes), and several goals bound to one phase queue up in the order they are written in the pom. Press all four modes, but especially the third:
One last cross-article trap: the JDK Maven uses is not necessarily the java on your terminal. The Java version printed by mvn -v is the one your build really runs on, and this mode shows how it gets picked on a machine with several JDKs:
Enough buttons — time to type. This console is wired to the same in-browser kernel and every reply is computed there. Start with lab mvnlife phases, then work down the list:
run lab mvnlife bind immediately followed by lab mvnlife direct — the lines the second one lacks are exactly the goals listed by the first. Once that contrast clicks, the question "why did it run my tests when I only said package" disappears for good.
You already know the rules (nearest wins, then first declared). What is hard is what to do after the verdict. Pick the shape of the conflict on the left, pick your remedy on the right, and the panel shows the resulting dependency:tree plus a risk rating:
No matching result
the least intuitive cell is "BOM overrides + declare directly" — you write a version, believe it won, and dependencyManagement silently rewrites it. There is only one way to be sure: run mvn dependency:tree and look for the (version managed from ...) note.
| Error text (excerpt) | Real cause | 30-second fix | Dig deeper in |
|---|---|---|---|
'mvn' 不是内部或外部命令 / 'mvn' is not recognized as an internal or external command | Maven's bin is not on PATH, or you edited PATH without reopening the terminal | Add %MAVEN_HOME%\bin to PATH, open a new window, verify with mvn -v | #1 environment variables |
The JAVA_HOME environment variable is not defined correctly | Maven found no JDK (this article is about Maven, but Maven still needs JAVA_HOME) | Point it at the JDK root, never at \bin | #1 Section 3 |
Could not resolve dependencies for project ...: Failed to collect dependencies at com.example:A:jar:1.0 | That coordinate does not exist in any reachable repo, or the network/mirror is broken | Search the coordinate on the repository website first, then retry with mvn -U | Section 2 |
Could not find artifact com.yourcorp:xxx:jar:1.0 in aliyun-central | mirrorOf set to *, hijacking even private-repo traffic | Mirror only central, or write *,!your-private-repo | Section 2.1 |
java.lang.NoSuchMethodError: 'byte[] com.fasterxml.jackson.databind.ObjectMapper.writeValueAsBytes(java.lang.Object)' | Compiled against one version, ran against another — textbook mediation accident | mvn dependency:tree -Dincludes=com.fasterxml.jackson.core | Section 6 |
java.lang.NoClassDefFoundError: com/mysql/cj/jdbc/Driver or a runtime ClassNotFoundException | The JDBC driver was scoped provided/test, so it never joined the runtime classpath | Change it to runtime (or leave the default compile) | Section 4 scope sandbox |
[WARNING] The POM for xxx is missing, no dependency information available, repeated failures for one artifact | A previous failed download left .lastUpdated markers in the local repo | Delete that artifact folder in ~/.m2/repository, or retry with -U | Section 2.2 |
duplicate dependency warning / the same GA appearing twice | The same dependency declared twice across parent and child poms | Clean up with dependency:analyze; centralise in dependencyManagement | Section 8 |
on an air-gapped machine the confusing one is "it is cached, yet still reported missing". The failed download left *.lastUpdated files behind, and Maven then believes it already tried that coordinate and will not go online again. Delete the artifact directory and re-resolve, or force it with -U.
That NoSuchMethodError row is the chapter's signature "Maven did nothing wrong and you still broke" scene. The stack below is real — do not read the answer, just click the frame you believe is guilty:
Everything is fine locally. The fatjar CI produced goes to production and the first export endpoint returns 500 with two lines of log.
Goal: manufacture a version conflict deliberately, then resolve it three different ways and feel the difference.
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>dep-lab</artifactId> <version>1.0.0</version> <properties> <maven.compiler.release>17</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- drags in commons-logging 1.2 --> <dependency> <groupId>commons-cli</groupId> <artifactId>commons-cli</artifactId> <version>1.5.0</version> </dependency> <!-- drags in commons-logging 1.0 --> <dependency> <groupId>commons-attributes</groupId> <artifactId>commons-attributes-compiler</artifactId> <version>2.1</version> </dependency> </dependencies></project>Run it and record the output:
mvn -q dependency:tree -Dincludes=commons-loggingExpected shape (indentation is depth; the loser is marked omitted):
[INFO] com.example:dep-lab:jar:1.0.0[INFO] +- commons-cli:commons-cli:jar:1.5.0:compile[INFO] | \- commons-logging:commons-logging:jar:1.2:compile[INFO] \- commons-attributes:commons-attributes-compiler:jar:2.1:compile[INFO] \- (commons-logging:commons-logging:jar:1.0:compile - omitted for duplicate)Now add code using an API that only 1.2 has, and observe what breaks at compile time versus runtime. Then try each remedy in turn: declare the version directly, add an <exclusion> to the first dependency, pin it in dependencyManagement — verifying every attempt with the same dependency:tree command.
Done when: you can explain in your own words what each of the three fixes actually changed, and why the third one suits a team.
- Swap the declaration order of the two dependencies and arrange them at equal depth (add one more intermediate dependency). You will observe the winning version flipping with the order — the fragility of letting line order decide runtime behaviour.
- Change
commons-logging's scope toprovided, runmvn package, thenjava -jar. You will observeNoClassDefFoundErrorat startup while the build stays completely green. - In
~/.m2/settings.xml, changemirrorOffromcentralto*and build a project that needs a private repo. You will observeCould not find artifact ...(without a private repo, reproduce the request-path change with an obscure Central-only coordinate).
Tip: after variant 1, revisit the "equal depth" column of the Section 12 sandbox — the conclusion becomes obvious.
Create a three-module project that exercises every concept in this article:
multi-lab/ <- parent pom: packaging=pom + <modules> + <dependencyManagement>├── lab-common/ <- one utility class, no third-party deps├── lab-service/ <- depends on lab-common, pulls a library with a conflicting transitive dep└── lab-web/ <- depends on lab-service, packaged as an executable fatjarRequirements: ① the parent manages versions in dependencyManagement and children never write <version>; ② at least one child uses <exclusions> with a comment saying why; ③ mvn clean install at the root passes; ④ mvn dependency:tree contains no omitted for conflict; ⑤ lab-web configures spring-boot-maven-plugin and produces a jar that java -jar runs directly.
Acceptance checklist: on a fresh machine (or after deleting the entire ~/.m2/repository), a single mvn clean install rebuilds everything identically — that, and only that, is a reproducible build.
what does each of groupId:artifactId:version denote, and why can these three strings replace a local jar path?
in what order do the two mediation rules apply? Does Maven ever prefer the newer version?
among compile / provided / runtime / test, which two most often cause "works locally, dies in production", and at which moment does each fail?
what separates dependencyManagement from dependencies, and why is the first called a "price list"?
which lifecycle phase does each word of mvn clean package -DskipTests trigger?
coordinates identify, repositories supply, nearest wins, scope gates, phases bind goals — GAV is the ID card, local then private then central are the shelves, the shortest path wins, scope decides where it is visible, and every piece of work is a plugin goal bound to a phase.
going from manual javac to Maven, what you gain is not "shorter commands" but a "declarative, reproducible build". Every concept in this article — coordinates, repositories, scope, mediation, lifecycles, aggregation and inheritance — serves one goal: on any machine, mvn clean package produces exactly the same result.