Build Tools & Modules

Maven Dependencies & Repositories

15 min Lesson 3 of 13

Maven Dependencies & Repositories

Declaring a dependency in a pom.xml is trivial — one XML block and Maven resolves, downloads, and wires everything. The sophistication lies in understanding what Maven fetches on your behalf, how it decides which version to use when two libraries disagree, and why the wrong scope can silently bloat a production artifact or break a downstream consumer. This lesson turns that background knowledge into deliberate, professional dependency management.

How Maven Resolves Dependencies

When you declare a dependency, Maven searches a chain of repositories in order until it finds the artifact:

  1. Local repository~/.m2/repository on your machine. Once downloaded, artifacts are cached here forever (until you delete them or run mvn dependency:purge-local-repository).
  2. CentralMaven Central, the public default. No configuration required.
  3. Additional remote repositories — declared in pom.xml or settings.xml (e.g. JitPack, Spring Milestones, your company Nexus/Artifactory).
Maven Central is not just a download server. It enforces strict publishing rules: every artifact must supply a POM, a sources JAR, a Javadoc JAR, and GPG signatures. This rigor is why Central is the trust anchor of the Java ecosystem.

A coordinate — groupId:artifactId:version — uniquely identifies an artifact. Maven maps it to a filesystem path in any repository: com/google/guava/guava/32.1.3-jre/guava-32.1.3-jre.jar.

Dependency Scopes

The <scope> element controls three things simultaneously: which classpath the dependency appears on (compile / test / runtime), whether it is included in the final artifact, and whether it propagates to consumers of your library. The six scopes are:

  • compile (default) — Available at compile time, runtime, and test time. Included in the packaged JAR/WAR and propagated to consumers as a transitive dependency. Use for APIs your code exposes or types that appear in your public signatures.
  • provided — Available at compile and test time but not packaged and not transitive. The runtime environment (container, JDK) supplies it. Classic example: jakarta.servlet-api in a WAR deployed to Tomcat.
  • runtime — Not needed to compile but required at runtime. Packaged, but excluded from the compile classpath so your code cannot accidentally depend on implementation details. JDBC drivers belong here.
  • test — Only the test compile and test runtime classpaths. Never packaged or propagated. JUnit, Mockito, AssertJ, Testcontainers.
  • system — Like provided but you supply an explicit path via <systemPath>. Avoid: it is non-portable and essentially deprecated.
  • import — Only valid in <dependencyManagement> with type=pom. Merges a Bill of Materials (BOM) into your dependency management. Covered in Lesson 9.
<dependencies> <!-- compile scope (default) --> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>32.1.3-jre</version> </dependency> <!-- provided: servlet API supplied by the container --> <dependency> <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> <version>6.0.0</version> <scope>provided</scope> </dependency> <!-- runtime: JDBC driver not needed at compile time --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.3.0</version> <scope>runtime</scope> </dependency> <!-- test: only available during test compilation and execution --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> </dependencies>
Scope discipline matters most for library authors. If you publish a library with a compile-scope dependency on SLF4J, every consumer inherits it transitively. If you publish with provided scope (and document the requirement), consumers choose their own SLF4J binding. Scope is part of your library's contract.

Transitive Dependencies

When you depend on spring-webmvc, it in turn depends on spring-core, spring-beans, and others. Maven reads each dependency's POM and recursively resolves the full graph. This is transitive dependency resolution — you declare one artifact and Maven silently pulls a tree of artifacts.

Scope propagates through the graph with these rules: a transitive dependency with compile scope stays compile; one with runtime scope stays runtime; one with test or provided scope is not propagated at all. This is why you never see JUnit leak into production classpaths.

To inspect the full resolved graph:

mvn dependency:tree # filter to a specific artifact mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind

Version Conflict Resolution: Nearest-Wins

The graph almost always contains the same artifact at multiple versions (diamond dependency). Maven applies the nearest-wins rule: the version declared closest to your project root in the dependency graph wins. If two paths have the same depth, the first one declared in the POM wins.

The nearest-wins rule is deterministic but can surprise you. If library A needs Jackson 2.17 and library B needs Jackson 2.15, and B is declared first at depth 2 while A pulls Jackson at depth 3, you get 2.15 — possibly incompatible with A's API calls.

Never silently accept a version chosen by nearest-wins. Run mvn dependency:tree and mvn dependency:analyze regularly. When a transitive version is wrong, override it explicitly in <dependencyManagement>: declare the artifact with the version you need and Maven pins every reference in the tree to that version, regardless of depth.
<!-- Pin a transitive dependency to a specific version --> <dependencyManagement> <dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.0</version> </dependency> </dependencies> </dependencyManagement>

Excluding Unwanted Transitives

Sometimes a library drags in a transitive dependency you do not want — a competing logging implementation, a vulnerable old version you cannot override globally, or an artifact that conflicts with your runtime environment. Use <exclusions> to cut it from the graph at the declaration site:

<dependency> <groupId>org.apache.kafka</groupId> <artifactId>kafka-clients</artifactId> <version>3.7.0</version> <exclusions> <exclusion> <groupId>org.slf4j</groupId> <artifactId>slf4j-reload4j</artifactId> </exclusion> </exclusions> </dependency>

Adding a Remote Repository

Some artifacts are not on Maven Central (Spring milestones, JitPack builds from GitHub, your company's private repo). Declare them in the POM or in ~/.m2/settings.xml:

<repositories> <repository> <id>spring-milestones</id> <url>https://repo.spring.io/milestone</url> </repository> </repositories>
Prefer Central and authenticated private repos over random public repositories. Dependency confusion attacks substitute a malicious public artifact for an internal name. Use <mirrorOf> in settings.xml to route all traffic through your company proxy (Nexus / Artifactory), which can scan artifacts before they reach developer machines.

Practical Diagnostic Commands

  • mvn dependency:tree — full resolved graph with scopes and version sources.
  • mvn dependency:analyze — flags used-but-undeclared and declared-but-unused dependencies.
  • mvn dependency:resolve -Dclassifier=sources — download sources JARs for IDE navigation.
  • mvn versions:display-dependency-updates — show which declared dependencies have newer releases.

Summary

Dependency management in Maven is a three-layer discipline: scopes constrain when and where a dependency is visible; transitive resolution automates the dependency graph but introduces version conflicts; repositories determine where Maven looks for artifacts. Mastering dependency:tree, understanding nearest-wins, and using <dependencyManagement> to pin versions are the skills that separate a developer who pastes coordinates from one who truly owns their build.