Think of your Java project as a complex machine, maybe a car. The code you write is the engine and the frame. But a car needs tires, a battery, a fuel pump, and hundreds of other parts you didn’t manufacture. In software, these parts are your dependencies—libraries for logging, database connections, or web frameworks. Now, imagine if every time you ordered a tire, the supplier also sent you a specific wrench, a specific jack, and three different types of lug nuts you didn’t ask for. Some of these extra items might be great. Others might be the wrong size and cause a problem when you try to use a different jack from another supplier. This is dependency management. It’s not just about getting the parts; it’s about managing the flood of extra parts that come with them, ensuring they all work together, and keeping the whole system secure and reliable for years. If you get it wrong, your project becomes a fragile, bloated, and insecure mess. If you get it right, you build a foundation that can last. I’ve spent years wrestling with these problems, and I want to show you ten practical ways to keep your project’s dependencies under control.
Let’s start with a fundamental tool: the Bill of Materials, or BOM. When you work with a large ecosystem like Spring Boot, you’re not using one library. You’re using Spring Web, Spring Data, Spring Security, and more. Each of these has its own version. Making sure they are all compatible is a headache. A BOM solves this. It’s a master list that says, “If you use Spring Boot 3.1.5, here are the correct, tested versions of every Spring-related library that goes with it.” You import this list once, and you no longer need to specify the version for each individual Spring dependency. It’s like the car manufacturer providing you with a certified parts list for your specific model year. Everything is guaranteed to fit together.
Here’s how you use it in Maven. You don’t add the BOM as a regular dependency. You import it into a special section called dependencyManagement. This section is your project’s control panel for versions.
<dependencyManagement>
<dependencies>
<!-- Import the Spring Boot BOM. It dictates versions for the whole ecosystem. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.1.5</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Notice no version tag! It's controlled by the BOM above. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
</dependencies>
Without the BOM, you might accidentally specify Spring Web 3.1.5 and Spring Data 3.0.0, which could be incompatible. The BOM makes inconsistency impossible for the libraries it covers. It’s your first line of defense against version chaos.
Now, the BOM handles a curated set of libraries. But your project will have others: Google’s Guava, Apache Commons, a JSON processor. This is where you take direct control of transitive dependencies. A transitive dependency is a library your dependency needs. For example, if you declare a dependency on Library A, it might internally require Library B version 2.0. If another part of your project pulls in Library C, which needs Library B version 1.0, you have a conflict. Which version wins? You might not even know this conflict exists until a strange ClassNotFoundException appears at runtime.
The dependencyManagement section is also your tool to enforce order here. You declare the version you want, and Maven or Gradle will use it, overriding any requests for different versions.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<!-- I'm explicitly choosing this version for my entire project. -->
<version>32.1.3-jre</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Some other library that might ask for an older Guava. -->
<dependency>
<groupId>com.example</groupId>
<artifactId>some-library</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
In this case, even if some-library was built with Guava 31.1, your project will force the use of 32.1.3. This is powerful, but it comes with responsibility. You must test that your chosen version works correctly with all the libraries that use it. The command mvn dependency:tree is your best friend here. It shows you a visual tree of every dependency, direct and transitive, and which version was finally selected. Run it often.
Sometimes, a dependency brings along something you simply do not want. It might be a legacy XML parser that clashes with a newer one you’re using, or an optional logging framework that adds bulk. You can surgically remove it using an exclusion. Think of it as telling the supplier, “Send me the tire, but leave out the lug nuts you usually include. I have my own.”
Exclusions are declared on the specific dependency that is causing the issue.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.1.5</version>
<exclusions>
<!-- Exclude the default embedded Tomcat server. -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Now I can include a different server, like Jetty. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
In Gradle, the syntax is similarly straightforward.
dependencies {
implementation('org.springframework.boot:spring-boot-starter-web') {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
}
implementation 'org.springframework.boot:spring-boot-starter-jetty'
}
A word of caution: exclusions are local to that dependency path. If another library also pulls in the excluded artifact, it will come back. Always check the dependency tree after applying exclusions to ensure the unwanted library is truly gone.
Now let’s talk about two important dependency “scopes”: provided and optional. Scopes tell your build tool when and where a dependency is needed. Using them correctly makes your artifact leaner and your intentions clearer.
The provided scope is for dependencies that are expected to be present in the runtime environment. The classic example is the Servlet API in a Java web application. Your code needs it to compile, but you don’t package it with your WAR file because the application server (like Tomcat or Jetty) already provides it.
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope>
</dependency>
If you marked this as a regular compile dependency, you’d be bundling it, potentially causing version conflicts with the server’s own libraries. The provided scope keeps it off the final deployment package.
The optional flag is different. It marks a dependency that is needed for a specific, non-core feature of your library. If I’m building a utility library that can optionally export data to JSON, I might make the JSON library optional.
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.3</version>
<optional>true</optional>
</dependency>
This means two things. First, this dependency is not transitive. If another project depends on my library, they will not automatically get Jackson. Second, it signals to users: “This feature needs an extra library. If you want it, you must declare Jackson yourself.” It prevents forcing heavy dependencies on users who don’t need the JSON feature. In your own projects, you can use optional dependencies for features like experimental integrations or alternate output formats.
Security cannot be an afterthought. Every library you include is a potential entry point for an attacker. New vulnerabilities are discovered in open-source libraries all the time. You must have a process to check for them. I integrate the OWASP Dependency-Check tool directly into my build. It scans your dependencies against a database of known vulnerabilities and produces a report.
You can run it manually from the command line.
mvn org.owasp:dependency-check-maven:check
But the real power comes from automating it. Configure it in your pom.xml to run on every build and fail if a critical vulnerability is found. This turns a passive concern into an active gate.
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>8.4.2</version>
<executions>
<execution>
<goals><goal>check</goal></goals>
<!-- Configure it to fail the build on high-severity issues -->
<configuration>
<failBuildOnAnySeverity>true</failBuildOnAnySeverity>
</configuration>
</execution>
</executions>
</plugin>
When the build fails, you get a report detailing the vulnerable library, the CVE identifier, and a severity score. Your job is then to update that dependency to a patched version. This practice shifts security left in your development cycle, catching problems long before they reach production.
Sometimes, you need to deliver a single, runnable jar file—often called an uber-jar or fat jar. Tools like the Maven Shade Plugin package all your classes and your dependencies’ classes into one jar. This is convenient for distribution, but it creates a “flat classpath.” All those separate jars are now merged. What happens if two libraries have a file with the same name and path, like META-INF/services/some.Interface? The last one in wins, which can break functionality.
The Shade Plugin allows you to manage these mergers through transformers.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<filters>
<!-- Remove signature files to avoid security warnings. -->
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<!-- Crucial: Merge service files instead of overwriting. -->
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>reference.conf</resource> <!-- For Akka configs, for example -->
</transformer>
</transformers>
<finalName>my-application-${project.version}-shaded</finalName>
</configuration>
</execution>
</executions>
</plugin>
The ServicesResourceTransformer is particularly important for Java’s ServiceLoader mechanism, used by many libraries for extensibility. Without it, your shaded jar might only contain one service definition, breaking plugins. Always test your shaded jar thoroughly; the runtime behavior can differ from running in your IDE with a normal classpath.
Reproducibility is a golden rule. A build from the same source code, on a different machine, six months from now, should produce a bit-for-bit identical artifact. This is impossible if your build tool can silently pick up new patch versions of dependencies. The solution is dependency locking. It pins every single transitive dependency to an exact version and records that in a lock file, which you commit to version control.
Gradle has first-class support for this.
// In your build.gradle, enable locking for all configurations
dependencyLocking {
lockAllConfigurations()
}
// To generate or update the lock state, you run:
// ./gradlew dependencies --update-locks org.apache.commons:commons-lang3,com.google.guava:guava
This command creates or updates a gradle.lockfile in your project. It looks like a simple list of dependencies and their exact coordinates. Once this file exists, Gradle will use only the versions specified in it, regardless of what newer versions are available in the repository. To update, you must intentionally run the update command. This practice eliminates “it works on my machine” problems caused by dependency drift and is essential for reliable CI/CD pipelines.
While we focus on application dependencies, build plugins are dependencies too. The Maven Compiler Plugin, the Surefire test plugin, any code quality tool—these evolve. If you don’t pin their versions, your build process itself can change unpredictably. One day, the default Java version for compilation might change, or a test report format might shift. You control this in the pluginManagement section.
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<!-- No version needed here; it's inherited from management. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
</plugins>
</build>
This ensures every developer and every CI server uses the exact same tooling. Updating a plugin version becomes a conscious, tracked decision, not a hidden variable in your build.
If you use Gradle, there’s a fantastic feature for modernizing dependency declarations: Version Catalogs. Instead of scattering dependency coordinates as strings across multiple build.gradle files, you define them centrally in a TOML file. This gives you type-safety, easy refactoring, and a single source of truth.
You create a file at gradle/libs.versions.toml.
[versions]
# Define your version numbers once.
spring-boot = "3.1.5"
guava = "32.1.3-jre"
slf4j = "2.0.9"
[libraries]
# Define the libraries, referencing the versions above.
spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" }
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
guava = { group = "com.google.guava", name = "guava", version.ref = "guava" }
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
[bundles]
# Group related libraries together.
spring-core = ["spring-boot-starter", "spring-boot-starter-web"]
In your build.gradle file, you use these catalog entries. Your IDE will offer autocompletion.
dependencies {
// Clean, readable, and refactorable.
implementation libs.spring.boot.starter.web
implementation libs.guava
// Or use a whole bundle with one line.
implementation libs.bundles.spring.core
}
This approach eliminates magic strings, reduces copy-paste errors, and makes it trivial to update a version across a multi-module project. It’s a significant step up in maintainability.
Finally, let’s address the ongoing maintenance burden. All these techniques set up a solid structure, but dependencies keep evolving. Manually checking for updates is a chore that gets skipped. This is where automation shines. Tools like Dependabot (built into GitHub) or Renovate can monitor your dependency files and automatically create pull requests when updates are available.
You configure them with a file in your repository. Here’s a simple example for Dependabot in .github/dependabot.yml.
version: 2
updates:
- package-ecosystem: "maven"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
versioning-strategy: "increase-if-necessary"
This tells Dependabot to check Maven dependencies weekly, open PRs for updates, and limit itself to 10 open PRs at a time to avoid overwhelming you. You can configure it to ignore major version jumps, group updates from the same ecosystem, and even run your CI tests on the PR. When a PR comes in, you see the changelog, run your tests, and merge. It transforms a large, scary annual upgrade into a flow of small, manageable updates. This constant, incremental maintenance is the heartbeat of a sustainable project.
Managing dependencies is not a one-time task. It’s a discipline. It’s the practice of consciously choosing what goes into your project, understanding the relationships, and setting up systems to keep it healthy over time. These ten techniques—from BOMs and version locking to automated updates—form a complete strategy. They help you move from reacting to dependency problems to proactively preventing them. Your future self, and anyone else who works on your code, will thank you for the clean, stable, and secure foundation you’ve built. Start applying them one by one. The difference in your project’s long-term health will be profound.