Every project starts with a clean build. Then someone adds Jackson for JSON parsing, Guava for collections, and a logging framework because 'we might need it.' Six months later, your build.gradle looks like a library catalog, and a clean build takes 12 minutes. You're not alone—teams everywhere struggle with dependency creep. This guide is for the developer who has 20 minutes to triage, not a full sprint. We'll give you a repeatable checklist to cut bloat, fix conflicts, and keep builds fast, without rewriting your entire dependency tree.
Where Dependency Bloat Shows Up in Real Work
Dependency bloat doesn't announce itself with a red banner. It shows up gradually: CI builds that used to take 3 minutes now take 8; your IDE hangs when refreshing Gradle; or you discover two versions of the same library on the classpath only after a cryptic NoSuchMethodError in production. These are symptoms, not the root cause. The root cause is almost always the same: dependencies added without a clear exit strategy, or kept because 'someone might need it.'
In a typical project we've seen, a Spring Boot app with 40 direct dependencies pulled in over 200 transitive ones. After a 20-minute triage using the checklist below, the team removed 12 unused direct dependencies and 60 transitive ones, shaving 2 minutes off the build. The key was not to audit everything at once, but to focus on high-leverage areas: compile-only dependencies, test fixtures, and plugins that pull in large transitive trees.
Another common scenario is the 'dependency hell' of microservices. One service might use Jackson 2.10, another 2.12, and a shared library pulls in 2.11. The result? ClassLoader conflicts that are a nightmare to debug. A quick triage with gradle dependencies --configuration compileClasspath reveals the mess, and dependency locking can freeze versions across services.
The cost of ignoring bloat isn't just build time. It's also security surface area—each dependency is a potential vulnerability. And it's cognitive load: every time a developer adds a new feature, they have to wonder which of the 50 libraries already provides that utility. A lean dependency tree is a maintainable one.
Where to Start: The 20-Minute Triage Scope
You don't need to review every single dependency. Focus on the top offenders: libraries that are large (like Guava or Apache Commons), those with many transitive dependencies, and any dependency that was added more than a year ago. In 20 minutes, you can scan the top 10 dependencies by size and identify at least 2-3 that can be removed or scoped down.
Foundations Developers Often Confuse
Before we dive into the checklist, let's clear up a few concepts that trip up even experienced teams. First, the difference between compile-only and implementation dependencies. Many developers use implementation for everything, but if a dependency is only needed at compile time (like an annotation processor), it should be compileOnly. Misusing implementation leaks that dependency to consumers, bloating their classpath too.
Second, transitive dependency management. Gradle's default behavior is to pull in whatever transitive dependencies the direct dependency declares. But you can exclude specific transitive dependencies or use constraints to force a version. Many teams don't realize they can exclude entire groups—like exclude group: 'org.apache.commons'—if they know they don't need that functionality.
Third, the difference between api and implementation. If you're writing a library, api exposes a dependency to consumers, while implementation hides it. Using implementation by default reduces the surface area of your library. But for application modules, implementation is almost always correct—you don't want to leak your internal Jackson version to other modules.
Finally, dependency locking. This feature, enabled with dependencyLocking { lockAllConfigurations() }, creates a lockfile that pins all transitive versions. Many teams skip it because it adds a step to updates, but it prevents surprise upgrades from breaking the build. It's especially valuable in CI environments where reproducibility matters.
Common Misconception: 'I Need All These Dependencies'
When we ask teams why they keep a dependency, the most common answer is 'we use it somewhere.' But often, that 'somewhere' is a single utility method that could be replaced with a few lines of plain Java or Kotlin. For example, many projects include Guava just for Joiner or Splitter, which are trivial to implement. The same goes for Apache Commons Lang3—often used for StringUtils, which Java 11+ already covers with isBlank() and strip().
Patterns That Usually Work
Based on what we've seen across dozens of projects, these patterns consistently reduce dependency bloat without breaking functionality.
1. The 'Unused Dependency' Scan
Run gradle buildHealth from the Gradle Versions Plugin (com.github.ben-manes.versions) to get a report of unused dependencies. Alternatively, use the Gradle Dependency Analysis Plugin (com.autonomousapps.dependency-analysis) which is more thorough. In 5 minutes, you'll have a list of candidates to remove. But don't remove them blindly—check if they're used via reflection or SPI.
2. Scope Down to 'compileOnly' and 'testImplementation'
Review each dependency's scope. If a library is only used in tests, it should be testImplementation. If it's only used during compilation (like Lombok or Dagger), it should be compileOnly. This simple change can cut the runtime classpath significantly. We've seen projects reduce their runtime dependencies by 30% just by fixing scopes.
3. Use Dependency Constraints for Version Consistency
Instead of adding the same version to every module, declare a constraints block in the root project. This centralizes version management and prevents drift. For example:
dependencies { constraints { implementation('com.fasterxml.jackson.core:jackson-databind:2.13.4') } }Then each module can declare implementation 'com.fasterxml.jackson.core:jackson-databind' without a version, and Gradle will apply the constraint. This makes upgrades a one-line change.
4. Lock Transitive Versions
Enable dependency locking with dependencyLocking { lockAllConfigurations() }. Generate the lockfile with gradle dependencies --write-locks. This ensures that every build uses exactly the same transitive versions, eliminating 'works on my machine' issues. The downside is that you need to regenerate the lockfile when you update dependencies, but the stability gain is worth it.
5. Modularize with 'java-library' Plugin
If you have a multi-module project, apply the java-library plugin to library modules. This forces you to explicitly declare which dependencies are api (exposed to consumers) and which are implementation (hidden). It's a discipline that pays off in reduced coupling and faster builds.
Anti-Patterns and Why Teams Revert
Not every 'optimization' works. Here are the anti-patterns we've seen teams try—and why they often revert within a month.
Anti-Pattern 1: The 'Big Bang' Dependency Cleanup
Some teams set aside a whole sprint to 'clean up dependencies.' They remove everything that looks unused, update versions, and switch to new APIs. Then the build breaks, tests fail, and they spend days fixing issues. The problem is scope: dependency changes have far-reaching effects, especially with transitive dependencies. A better approach is incremental cleanup—one or two dependencies per week, with thorough testing.
Anti-Pattern 2: Overusing 'exclude' Blocks
It's tempting to exclude transitive dependencies to reduce bloat. But if you exclude a library that a direct dependency needs at runtime, you'll get ClassNotFoundException at the worst possible moment. We've seen teams exclude org.apache.logging.log4j to avoid version conflicts, only to break logging in production. A safer approach is to use constraints to force a specific version rather than excluding entirely.
Anti-Pattern 3: Ignoring the 'provided' Scope in Multi-Module Projects
In multi-module projects, some teams put common dependencies in a parent POM or shared build.gradle with api scope. This leaks those dependencies to all modules, even if only one module needs them. The result is a bloated classpath for every module. Instead, use implementation in the specific module that needs the dependency, and only elevate to api if the dependency is part of the module's public API.
Anti-Pattern 4: Automating Dependency Updates Without Testing
Tools like Dependabot or Renovate can automatically create PRs for dependency updates. That's great for security patches, but auto-merging minor updates can introduce breaking changes—especially with libraries that don't follow semver. We've seen teams revert to manual updates after a 'minor' Jackson upgrade changed serialization behavior. Always run your full test suite before merging dependency updates.
Maintenance, Drift, and Long-Term Costs
Even after a successful triage, dependencies drift. New libraries get added, old ones aren't removed, and versions diverge. The long-term cost of ignoring drift is a gradual return to bloat. Here's how to keep your build lean over time.
Set a Regular Triage Cadence
Schedule a 20-minute dependency review every two weeks. Use the same checklist: run gradle buildHealth, check for unused dependencies, and review scopes. This is a lightweight habit that prevents bloat from accumulating. Some teams add it to their sprint review as a 'health check' item.
Monitor Build Time Trends
Track your clean build time over time. If you see a sudden increase, investigate which dependency was added or updated. Tools like Gradle Enterprise or Build Scan can help identify the slowest tasks. Often, a single dependency with a large transitive tree is the culprit.
Use a Bill of Materials (BOM) for Consistent Versions
For frameworks like Spring Boot or Micronaut, use their BOM to manage versions. This centralizes version management and ensures that all modules use compatible versions. When you upgrade the BOM, all dependencies are updated together, reducing the risk of conflicts.
Archive Old Modules
If you have modules that are no longer actively developed, consider archiving them or moving them to a separate repository. They still contribute to build time and dependency resolution, even if they're not used. A simple settings.gradle change can exclude them from the build.
Educate the Team
Dependency bloat is a team problem. Share the triage checklist with your colleagues, and make it part of code review. When someone adds a new dependency, ask: 'Is this already provided by another library? Can we scope it down? Is there a lighter alternative?' Over time, this culture shift reduces bloat at the source.
When Not to Use This Approach
The 20-minute triage is not a silver bullet. Here are scenarios where you should invest more time or use a different strategy.
When You Have a Monolith with Hundreds of Dependencies
If your project has 200+ direct dependencies, a 20-minute scan won't cut it. You need a systematic audit, possibly with automated tooling like the Dependency Analysis Plugin's buildHealth report, and a multi-session effort to clean up. In this case, consider breaking the monolith into smaller modules first.
When You're Preparing for a Major Framework Upgrade
If you're upgrading from Spring Boot 2 to 3, or from Java 11 to 17, don't do a dependency triage at the same time. The upgrade itself will change dependencies, and you'll waste time cleaning up things that get replaced. Do the triage after the upgrade is stable.
When You Have Strict Compliance Requirements
Some industries require using specific versions of libraries (e.g., for security certification). In that case, you can't simply remove or upgrade dependencies without approval. Work with your compliance team to understand which dependencies are mandated, and focus on scoping down the rest.
When the Build Time Is Acceptable
If your clean build takes under 2 minutes and you rarely have dependency conflicts, the triage might not be worth the effort. The 20-minute checklist is for projects where bloat is already causing pain. If ain't broke, don't fix it—but keep an eye on trends.
Open Questions / FAQ
How do I find unused dependencies in a multi-module project?
Use the Gradle Dependency Analysis Plugin (com.autonomousapps.dependency-analysis). Run gradle buildHealth for a per-module report. It shows which dependencies are unused, used only in tests, or can be scoped down. For cross-module analysis, use gradle projectHealth.
What's the best way to handle version conflicts?
Use gradle dependencies --configuration compileClasspath to see the conflict resolution. Then add a constraints block in the root project to force a specific version. For example, if two libraries pull different versions of Jackson, you can force 2.13.4 for all modules.
Should I use 'compileOnly' for Lombok?
Yes. Lombok is only needed at compile time (for annotation processing). Use compileOnly 'org.projectlombok:lombok' and also add annotationProcessor 'org.projectlombok:lombok' for annotation processing. This keeps it out of the runtime classpath.
How often should I regenerate dependency locks?
Regenerate locks whenever you change direct dependencies or upgrade versions. You can automate this with a CI step that runs gradle dependencies --write-locks after a successful build on a branch. Some teams regenerate locks weekly to pick up transitive security patches.
Is it safe to remove a dependency that's only used in one method?
It depends. If the method is critical and the dependency is large, consider replacing it with a few lines of code. But if the dependency is small (like a single utility class), the cost of replacement may outweigh the benefit. Use your judgment—the triage is about reducing bloat, not eliminating every single external library.
Summary + Next Experiments
Dependency bloat is a gradual problem, but a 20-minute triage can reverse the trend. Start with the checklist: scan for unused dependencies, fix scopes, use constraints, and enable locking. Avoid the anti-patterns of big-bang cleanups and overzealous excludes. And build a maintenance habit—regular reviews, build time monitoring, and team education.
Here are three experiments to try this week:
- Run the unused dependency scan on your main module. Remove at least one unused dependency and measure the build time impact.
- Fix the scope of one dependency from
implementationtocompileOnlyortestImplementation. Verify that tests still pass. - Enable dependency locking in one module. Generate the lockfile and commit it. Notice how it affects reproducibility in CI.
After these experiments, you'll have a feel for the effort and payoff. Then you can decide how deep to go. The goal isn't a zero-dependency project—it's a consciously managed one, where every library earns its place. And that starts with 20 minutes and a checklist.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!