CodeGym /Courses /Spring Boot /Transitive dependencies and classpath

Transitive dependencies and classpath

Spring Boot
Level 4, Lesson 2
Available

1. Direct and transitive dependencies

When you are just getting started, it is very tempting to believe in a simple worldview: “I added one dependency — one jar came into the project.” A beautiful fairy tale, roughly like “I’ll eat one slice of pizza and stop.” In reality, dependencies behave differently: you add one library, and it drags along friends, friends of friends, and a couple of “useful extras just in case.”

Let us start with the terms, but without being dull. Direct dependency is what you wrote yourself in dependencies{...}. For example, you explicitly connected spring-boot-starter-webmvc. Transitive dependency is what came “inside” the direct dependency, because it itself needs other libraries to work.

To avoid confusion, it helps to keep a short table in mind:

Term What it means in plain language Where you can see it
Direct dependency “I asked for this” build.gradle.kts
Transitive dependency “It asked for itself” in the dependency tree
Dependency graph / tree “The full picture of what really came in” ./gradlew dependencies

It is important not to demonize transitive dependencies. They exist not because “Gradle wants to make life harder,” but because libraries are written by people too, and they use other people’s code as well. If a library can parse JSON, it needs something for JSON. If a library starts an HTTP server, it needs a container, an API, and a lot of infrastructure. And if all of that were connected manually, our course would turn into “the archaeology of jar files” for about 28 days straight.

A small diagram to reinforce the idea of “direct → transitive”:

flowchart TD
    A["build.gradle.kts
direct dependencies"] --> B["starter/webmvc"] A --> C["starter/actuator"] B --> D["spring-web"] B --> E["spring-webmvc"] B --> F["tomcat-embed-*"] B --> G["jackson-*"] C --> H["micrometer-*"] C --> I["spring-boot-actuator-*"]

This diagram does not have to match your tree one-to-one, but it shows the main idea: a starter is an entry point into an entire “branch” of dependencies.

2. Classpath and its role

Classpath sounds like a word from ancient manuscripts, but it is actually very practical. Classpath is the list, more precisely the set, of all libraries and classes available to your application at a specific moment: during compilation, during startup, and during test execution.

Imagine that your application is a student at an exam, and classpath is the books lying on the desk. If the needed book is there, it can be opened and copied from — that is, the class can be used. If the book is not there, you will have to “write it yourself” — meaning either add the dependency or give up using it.

In Java, this shows up quite literally. If a class is on the classpath, you can import it:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ClasspathProbe {
    // The logger exists only if slf4j-api is actually present on the classpath
    private static final Logger log = LoggerFactory.getLogger(ClasspathProbe.class);

    public static void main(String[] args) {
        // If slf4j-api is missing, this class will not even compile (Logger/LoggerFactory will not be found)
        log.info("I exist because slf4j is on the classpath"); // output example depends on logging configuration
    }
}

If the slf4j-api library were not available, such code would not even compile: the IDE would underline Logger in red, and the compiler would say “I do not know such a class.”

The subtlety is that classpath comes in different flavors. In the broadest sense, it looks like this:

“Whose classpath is this” When it is used Typical Gradle configuration
Compile classpath when we compile src/main/java compileClasspath
Runtime classpath when we run the application runtimeClasspath
Test classpath when we run tests testRuntimeClasspath /
testCompileClasspath

For us right now, one thought is important: application behavior is determined not only by what we wrote in dependencies, but by what actually ended up on runtimeClasspath. And test behavior is determined by what ended up on testRuntimeClasspath. These are close, but not identical worlds.

3. Dependency configurations in Gradle

When you first run ./gradlew dependencies(), you may experience a slight culture shock: Gradle prints a ton of text, and at the top there are mysterious headings like runtimeClasspath, testRuntimeClasspath, compileClasspath. This is not mockery, but the key to understanding where exactly the library lives and when it will be available.

In Gradle, dependencies are arranged into configurations. A configuration is simply a “dependency basket for a specific task.” One basket for compilation, another for runtime, a third for tests, a fourth for annotation processors. This way, Gradle can honestly assemble exactly what is needed right now and not drag everything everywhere.

For the catalog-service project, at an early stage you will most often encounter the following kinds of dependencies — treat this as a glossary, not as an exam:

In build.gradle.kts Idea Ends up in runtime?
implementation needed for both compilation and runtime yes
runtimeOnly needed only at runtime yes
testImplementation needed only for tests no (test runtime only)
compileOnly needed for compilation, but should not ship in runtime no
annotationProcessor needed by the compiler for code/metadata generation no

Here it is less about memorizing names and more about catching the idea: if a library is in the wrong basket, you can easily get the classic situation “everything is green in the IDE, but it crashes at runtime” — or vice versa. But we will not fix such things by fortune-telling; we will do it by reading the dependency tree.

4. ./gradlew dependencies without panic

The ./gradlew dependencies command is like an X-ray: it shows what is inside. And like any X-ray, it looks scary at first, because there are a lot of “bones” there. Our job is to learn how to look at the output so that the brain does not turn into a screenshot.

The simplest version of the command looks like this:

# Shows the dependency tree for many configurations (the output will be large)
./gradlew dependencies

Gradle will print a tree for many configurations, and there really will be a lot of text. Therefore, in practice it is almost always more useful to look at a specific configuration right away to remove noise. To understand what will actually be available to the application at runtime, usually this is enough:

# Main tree for application runtime
./gradlew dependencies --configuration runtimeClasspath

And for tests:

# Main tree for running tests (there is a different classpath there)
./gradlew dependencies --configuration testRuntimeClasspath

Now — the most important part: how to read this tree.

Gradle prints dependencies roughly like this (I will cut the output down a lot and keep only the idea):

runtimeClasspath - Runtime classpath of source set 'main'.
\--- org.springframework.boot:spring-boot-starter-webmvc
     +--- org.springframework.boot:spring-boot-starter
     |    \--- org.springframework.boot:spring-boot
     +--- org.springframework:spring-webmvc
     |    \--- org.springframework:spring-web
     +--- org.apache.tomcat.embed:tomcat-embed-core
     \--- ... (a lot more)

Here is what matters. At the top is the configuration, that is, “which set of tasks are we looking at dependencies for.” Below that are branches. Branching means: “this dependency depends on these.” The indentation and the symbols \--- and +--- are just visual markers of nesting. The deeper the indentation, the farther the dependency is from your build.gradle.kts.

And yes, the tree will be long. That is not an error. It is the normal price for writing one line of spring-boot-starter-webmvc and getting a working web stack, not an empty class with public static void main().

5. Dependency tree of catalog-service

When we say “a starter is connected in the project,” it sounds too abstract. Let us ground this as much as possible and look at our catalog-service. At this stage it is still small, but the dependencies already have something to show.

Our working baseline for the day is simpler — a web starter and a test starter. But to immediately see several different dependency roles in the tree, let us take an expanded version on top of it. This is not a new required project snapshot, but a convenient slice for reading the graph:

dependencies {
    // Web stack: Spring MVC + embedded server + JSON and everything needed "along the way"
    implementation("org.springframework.boot:spring-boot-starter-webmvc")

    // Observability: metrics, actuator endpoints, etc.
    implementation("org.springframework.boot:spring-boot-starter-actuator")

    // Spring Boot test stack (goes only into test configurations)
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    // Metadata generation for configuration (used by the compiler, not needed in runtime)
    annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
}

It is useful here precisely because the dependency model immediately shows ordinary runtime dependencies, a test-only dependency, and an annotation processor as a separate participant in the graph.

Now run:

# Look at what will actually end up on the application's runtime classpath
./gradlew dependencies --configuration runtimeClasspath

And in the output, look for familiar anchors. In the current baseline you will almost immediately see spring-boot-starter-webmvc, tomcat, jackson, logback; in the expanded version, micrometer will also be added. The useful strategy here is simple: do not try to read everything at once. Find an anchor and read only the needed branch.

For example, you may see that spring-boot-starter-webmvc unfolds into several large semantic parts: the base Boot layer, the Spring MVC layer, the JSON layer, and the embedded server. Even if you do not yet know what each library does, you can already answer an important question: “Where did this come from?” — “From this branch of the tree.”

To better feel the structure, here is a simplified “map” of the web starter dependency — this is not the exact Gradle output, but a teaching summary of the semantic blocks:

spring-boot-starter-webmvc
├─ spring-boot-starter
│  ├─ spring-boot
│  └─ spring-boot-autoconfigure
├─ spring-webmvc
│  └─ spring-web
├─ JSON stack
│  ├─ jackson-databind
│  ├─ jackson-core
│  └─ jackson-annotations
└─ embedded server
   ├─ tomcat-embed-core
   └─ tomcat-embed-websocket (may be)

If you see in the tree that runtimeClasspath contains tomcat-embed-core, that does not mean you “accidentally connected Tomcat.” It means you chose a starter that expresses the web scenario, and the embedded server is part of that scenario.

In this expanded version, spring-boot-starter-actuator pulls in “observability” — and even if you have not configured anything yet, at the dependency level you can already see that parts of Micrometer and Spring Boot actuator modules have arrived in the project.

Now an important engineering thought: Classpath is a contract. If a library is on the classpath, it can potentially affect application behavior — even if you do not use it directly yourself. That is why the dependency tree is needed not only by the “assembler,” but by the developer too.

Marker -> and (*)

When you start looking closely at the output of dependencies, you will almost certainly see two typical markers that first look like a cipher.

The first marker is the arrow ->. It means that somewhere in the graph one version was requested, but Gradle ended up choosing another. An example — hypothetical, but very similar to reality:

+--- some.group:some-lib:1.2
|    \--- common.group:common-lib:1.0 -> 1.1

This reads as follows: some-lib wanted common-lib:1.0, but in the project, after dependency resolution, common-lib:1.1 will be used. The reasons can vary: another branch of the graph required 1.1, Gradle chose a more appropriate version, the version is managed by a platform, or a rule kicked in.

The second thing is (*). It usually means: “this dependency was already shown above, and Gradle does not print its branch again so the output does not grow even more.” It is literally a “see above” link. Example:

+--- common.group:common-lib:1.1
\--- another.group:another-lib:2.0
     \--- common.group:common-lib:1.1 (*)

So another-lib also depends on common-lib, but Gradle will not show the entire subtree again because it has already appeared.

These two markers are the minimal alphabet after which the tree stops being noise. The arrow tells you the final version, and (*) means “I am not printing the same thing twice.”

dependencyInsight as a magnifier

Sometimes the tree is enough to answer the question “what came in,” but not enough to answer “why exactly this one.” For example, you see jackson-databind in runtimeClasspath, but you want to know which branch brought it in and why that particular version was chosen.

For such cases, Gradle has the magnifier command dependencyInsight. It is convenient when you already know the dependency name and want to find the path in the graph.

It looks like this:

# Shows who pulled in the dependency and how the final version was chosen
./gradlew dependencyInsight --dependency jackson-databind --configuration runtimeClasspath

The output will be more “conversational”: Gradle will show who requested the dependency and how the final version was chosen. This is especially useful when there are many starters in the project and it is hard to find with your eyes where a particular library came from.

The same logic can be applied to another dependency. For example:

# The same "magnifier", but for the embedded server
./gradlew dependencyInsight --dependency tomcat-embed-core --configuration runtimeClasspath

Even if you do not yet “know Tomcat,” you can already quickly see that it sits inside the web starter branch, and that is expected.

The point of dependencyInsight is not to memorize all Gradle commands. The point is to stop making dependency decisions “by feel.” If you have a question about a dependency, you take facts from the tree and from insight, not from intuition and Google saying “it seems like it should be this way.”

6. Typical mistakes when reading a dependency tree and working with classpath

Mistake No. 1: looking only at build.gradle.kts and ignoring transitive dependencies.
Beginners often sincerely believe that the project contains exactly what is written in dependencies{...}. This leads to surprise in the style of “where did Tomcat come from?” or “why do I have Jackson, I did not add it.” The right habit here is very calm: build.gradle.kts shows intent, and ./gradlew dependencies shows reality. If you want to understand what is actually in the project, look at the tree.

Mistake No. 2: trying to “read the whole tree” and burning out in 30 seconds.
dependencies prints a lot of text, and the brain tries to read everything like a book. But this is not a book, it is a map. You read it by landmarks: first choose a configuration — runtimeClasspath or testRuntimeClasspath, then find an anchor — a starter or a specific library, and then read only the needed branch. Once you start reading the tree like a map, it stops being scary.

Mistake No. 3: not understanding the markers -> and (*) and drawing false conclusions because of that.
If you see 1.0 -> 1.1 and think “oh, Gradle is broken and changes everything by itself,” you start fixing what is not broken. The arrow simply says that the final version differs from the requested one, and that is often normal, especially in a platform world with managed versions. And (*) is not about the version at all, but about Gradle not duplicating the same branch.

Mistake No. 4: confusing “compilation” and “runtime” as if they were the same thing.
The naive model sounds like this: “if the code compiles, then everything is present at runtime.” But Gradle has different configurations, and a library may be available during compilation but absent at runtime — or vice versa. Even if you are not changing configurations by hand yet, it is useful to understand why we sometimes look at runtimeClasspath and sometimes at testRuntimeClasspath: these are different worlds with different rules.

Mistake No. 5: being afraid of a long dependency tree as a sign of a “bad project.”
A long tree is not a diagnosis. It is just the reality of a modern stack: Spring Boot, JSON, embedded server, logging, test stack — all of these are sets of components. A tree becomes a problem not because of its length, but because of symptoms: version conflicts, duplicate implementations of the same layer, unexpected “second” libraries with the same purpose. But the fact that there are “many dependencies” in a Boot project is completely normal, especially if you use starters.

1
Task
Spring Boot, level 4, lesson 2
Locked
Script for runtime and test classpath
Script for runtime and test classpath
1
Task
Spring Boot, level 4, lesson 2
Locked
dependencyInsight for a transitive dependency
dependencyInsight for a transitive dependency
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION