Dependency Scopes

Where each scope applies — and how scope propagates through transitive dependencies

← Back to Index

What Does Scope Control?

A dependency's scope determines three things: when it's available (compile time, test time, runtime), whether it's bundled into the final artifact, and how it propagates to anything that depends on your project transitively. Getting scope wrong produces two failure modes: bloated artifacts shipping libraries they don't need, or NoClassDefFoundError at runtime because something needed was scoped out.

Scope Compile Test Runtime Packaged Transitive
compile (default)
provided
runtime
test
system (avoid)
import Special — only for BOM imports inside dependencyManagement

Each Scope, Concretely

compile — the default, used for almost everything

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
    <version>6.1.0</version>
    <!-- no <scope> needed — compile is the default -->
</dependency>
// Use for: anything your code imports and calls directly — Spring, Jackson, Guava

provided — the server/container supplies it at runtime

<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.0.0</version>
    <scope>provided</scope>  <!-- Tomcat already has this on its classpath -->
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>provided</scope>  <!-- code-gen only, irrelevant after compilation -->
</dependency>
// Use for: Servlet API, Lombok, anything compiled against but supplied by the host

runtime — needed to run, irrelevant to compile your own code

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

// You code against the standard JDBC interface, never import org.postgresql.* directly
import java.sql.Connection;
import java.sql.DriverManager;
Connection conn = DriverManager.getConnection("jdbc:postgresql://localhost/db", "u", "p");
// The actual driver is discovered at runtime via java.util.ServiceLoader —
// this is WHY it compiles fine without the driver on the compile classpath.

test — never leaves src/test, never packaged

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
// Use for: JUnit, Mockito, AssertJ, H2/Testcontainers, spring-boot-starter-test
// Try importing org.junit in src/main/java — it won't compile. That's the point.

system — avoid, always

// ❌ Hardcoded absolute path — breaks on every other machine and in CI
<dependency>
    <scope>system</scope>
    <systemPath>${project.basedir}/lib/proprietary-lib.jar</systemPath>
</dependency>

// ✅ Install the JAR properly into a repository instead
mvn install:install-file -Dfile=lib/proprietary-lib.jar \
    -DgroupId=com.company -DartifactId=proprietary-lib -Dversion=1.0 -Dpackaging=jar
// Or better: push it to a private Nexus/Artifactory so the whole team can resolve it normally

How Scope Propagates Transitively

This is the part almost nobody internalises until it bites them: when a dependency you declare has its own dependencies, your declared scope and its declared scope combine — and the result isn't always intuitive.

Your declared scope ↓ / their scope → compile provided runtime test
compile compileruntime
provided providedprovided
runtime runtimeruntime
test testtest

"—" means the transitive dependency is dropped entirely — neither provided nor test scoped dependencies of a dependency propagate at all, regardless of your own declared scope.

// Real example: spring-boot-starter-web (compile scope, your declaration)
//   transitively depends on spring-core (compile scope, declared by the starter)
//   → spring-core ends up COMPILE scope in YOUR project — direct mapping

// Real example: spring-boot-starter-test (test scope, your declaration)
//   transitively depends on mockito-core (compile scope, declared by the starter)
//   → mockito-core ends up TEST scope in YOUR project — demoted to match your scope

// This demotion is exactly why a test-scoped starter never leaks testing
// libraries into your actual production artifact, even though the starter
// itself declares its internals as plain compile scope.

Gradle Configuration Equivalents

Maven scope Gradle configuration
compile implementation (or api if exposed to your own consumers)
provided compileOnly
runtime runtimeOnly
test testImplementation

For the full detail on why Gradle splits compile into implementation vs api — a distinction Maven scopes don't have — see Gradle Basics.

Real Pattern: A Typical Spring Boot Web App

<dependencies>
    <!-- compile (default) — used directly throughout the codebase -->
    <dependency><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>

    <!-- provided — code generation only, irrelevant after compilation -->
    <dependency><artifactId>lombok</artifactId><scope>provided</scope></dependency>

    <!-- runtime — concrete implementation behind a standard interface -->
    <dependency><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>

    <!-- test — JUnit, assertions, in-memory test DB -->
    <dependency><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
    <dependency><artifactId>h2</artifactId><scope>test</scope></dependency>
</dependencies>

Interview Questions

🎓 Junior level

Q: What is the difference between provided and runtime scope?
provided is needed to compile but NOT packaged — the runtime environment supplies it (the Servlet API, supplied by Tomcat). runtime is the opposite: not needed to compile your code, but needed and packaged for execution (a JDBC driver, accessed only through the standard java.sql interface in your code).

Q: Why does code using a runtime-scoped dependency still compile?
Because you code against a standard interface (e.g. java.sql.Connection) that's available at compile scope through the JDK itself. The concrete implementation (the PostgreSQL driver) is located dynamically at runtime via ServiceLoader — your source code never directly references the implementation class.

🔥 Senior level

Q: Explain how scope propagates to transitive dependencies, with a concrete example.
The transitive dependency's effective scope is derived from combining your declared scope with its own declared scope — but provided and test scoped transitive dependencies never propagate at all, regardless of how you declared the parent. Concretely: spring-boot-starter-test declared with test scope pulls in mockito-core (itself compile scope inside the starter's own POM) — but because YOUR declaration is test, mockito-core is demoted to test in your project. This is precisely the mechanism that prevents testing libraries from leaking into your production artifact even though the starter itself doesn't mark them test internally.

Q: Why might a Servlet API dependency at compile scope (instead of provided) cause a production failure?
If packaged into the WAR at compile scope, your bundled servlet-api.jar can conflict with the container's own (Tomcat/Jetty already provide it on the shared classloader) — leading to ClassCastException or LinkageError when the container's class and your bundled class, structurally identical but loaded by different classloaders, are treated as incompatible types at runtime. provided scope exists specifically to prevent this category of classloader conflict.