Dependencies and Scopes

Declare libraries with the right scope so tests, drivers and compile-time tools end up only where they belong, and untangle version conflicts.

Syntax<dependency> <groupId>org.postgresql</groupId><artifactId>postgresql</artifactId> <version>42.7.13</version><scope>runtime</scope> </dependency>

A dependency is another artifact your code needs, named by its coordinates. Maven and Gradle download it, and everything it depends on, which are its transitive dependencies. Adding one starter can bring in dozens of JARs, so the question is not only what you add but where it ends up.

Maven scopes

ScopeCompileTestsPackaged and runTypical use
compile (default)yesyesyesLibraries your code calls: HikariCP, Jackson
runtimenoyesyesJDBC drivers, logging back ends
providedyesyesnoAPIs the server supplies, compile-only tools
testnoyesnoJUnit, AssertJ, Mockito, Testcontainers

A JDBC driver belongs in runtime: your code is written against the java.sql interfaces, so it should not be able to import driver classes by accident. Test libraries in compile scope are a common slip that ships JUnit and Mockito inside a production JAR and into its vulnerability report. The system scope points at a JAR on disk and is deprecated; install such a JAR into a repository instead. import only appears in <dependencyManagement> for BOMs.

Scope is transitive too. compile and runtime dependencies of your dependencies come along; their test and provided ones do not. A dependency marked <optional>true</optional> is used by that library but not passed on to you.

Gradle configurations

Gradle uses configurations instead of scopes. implementation is the usual one, runtimeOnly matches runtime, compileOnly matches provided, and testImplementation and testRuntimeOnly cover tests. In a library built with the java-library plugin, api exposes a dependency to consumers' compile class path while implementation hides it.

Version conflicts

When two paths bring different versions of one library, only one can win. Maven picks the version nearest to your project in the tree, and the first declared when depths tie. Gradle picks the highest version requested. Either way you may run against a version that a library was not compiled for, which shows up as NoSuchMethodError or ClassNotFoundException at run time. Look at the tree before guessing, then fix the version in <dependencyManagement> or a BOM, or exclude the unwanted transitive artifact.

Run mvn dependency:analyze now and then. It lists classes you use from a dependency you never declared (it only arrives transitively and can vanish on an upgrade) and declared dependencies nothing uses.

Example

Example · xml
<dependencies>
  <!-- compile (the default): code imports these -->
  <dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>7.0.2</version>
  </dependency>

  <!-- runtime: needed to run, never imported by our code -->
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.13</version>
    <scope>runtime</scope>
  </dependency>

  <!-- test: never packaged into the service -->
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.0.3</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.27.7</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.23.0</version>
    <scope>test</scope>
  </dependency>
</dependencies>

When to use it

  • Keep JUnit, Mockito and Testcontainers out of the production JAR and out of the image's vulnerability scan.
  • Add the PostgreSQL driver in runtime scope so repository code can depend only on java.sql interfaces.
  • Track down a NoSuchMethodError after an upgrade by finding which dependency brought in an older Jackson.

More examples

The same dependencies in Gradle

Each Maven scope has a Gradle configuration with the same effect. The JUnit BOM supplies matching versions for junit-jupiter and the platform launcher, so neither needs its own version.

Example · kotlin
dependencies {
    implementation("com.zaxxer:HikariCP:7.0.2")
    runtimeOnly("org.postgresql:postgresql:42.7.13")

    testImplementation(platform("org.junit:junit-bom:6.0.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    testImplementation("org.assertj:assertj-core:3.27.7")
    testImplementation("org.mockito:mockito-core:5.23.0")
}

Excluding a transitive dependency

An exclusion removes one artifact from this dependency's subtree only. If another path still brings it in, it stays on the class path, so confirm with the dependency tree afterwards.

Example · xml
<dependency>
  <groupId>com.example</groupId>
  <artifactId>legacy-reporting-client</artifactId>
  <version>2.4.1</version>
  <exclusions>
    <!-- It drags in an old commons-logging; we route logging through SLF4J -->
    <exclusion>
      <groupId>commons-logging</groupId>
      <artifactId>commons-logging</artifactId>
    </exclusion>
  </exclusions>
</dependency>

Finding out where a JAR came from

dependencyInsight and the filtered tree answer which dependency pulled a library in and why that version was chosen. Run them before adding an exclusion or a forced version.

Example · bash
# Maven: the full tree, or only the paths that lead to one artifact
./mvnw dependency:tree
./mvnw dependency:tree -Dincludes=tools.jackson.core

# Used but undeclared, and declared but unused
./mvnw dependency:analyze

# Gradle: what is on the runtime class path and why this version won
./gradlew dependencies --configuration runtimeClasspath
./gradlew dependencyInsight --dependency jackson-databind --configuration runtimeClasspath

Discussion

  • Be the first to comment on this lesson.