Dependency Management in Spring Boot Microservices


The Friday afternoon that started it

You don’t have to imagine this one — it happened, at global scale. On the evening of 9 December 2021, a critical vulnerability in Log4j — a logging library so common it was running in a staggering share of the world’s Java software — went public. It scored the maximum severity and shipped with a patch-immediately advisory, and it came to be known as Log4Shell.

For engineering teams everywhere, that Friday evening became a scramble around one deceptively simple question. Picture a representative team — call it Project Atlas, running 14 Spring Boot microservices: an orders service, payments, inventory, notifications, a handful of back-office tools, and a couple of shared internal libraries everyone depends on. For Atlas, the question that decided whether anyone went home that weekend was:

Which of our 14 services are affected, what version of Log4j is each one actually running, and how many pom.xml files do we have to touch to fix it?

Nobody could answer confidently. Some services had opted into Log4j explicitly. Most had never mentioned it at all — it rode in transitively, pulled by other libraries, at whatever version Maven happened to resolve. Two services were several minor versions apart for no reason anyone remembered. What should have been a ten-minute version bump became a weekend of mvn dependency:tree, spreadsheets, and cross-team Slack messages.

That weekend is what this post is about. Not Log4Shell itself — the dependency management strategy that separates the teams who patched it in an afternoon from the teams who spent a weekend just figuring out where they were exposed. We’ll start where Atlas started, feel each pain point, and let the better design earn its place rather than declaring it up front.

First, a disambiguation

The word “dependency” is overloaded in the Spring world, and conflating the two meanings is one of the most common sources of confusion I see:

  • Dependency management (this post) is a build-time concern: how you declare, version, and resolve the third-party libraries your service pulls in. This is Maven, Gradle, starters, and BOMs.
  • Dependency injection is a run-time concern: how the Spring container wires your objects together — the Inversion of Control pattern, @Component, constructor injection, and friends.

They share a name and a loose principle (“don’t hard-code your dependencies — declare them and let a system resolve them”), but they operate at completely different layers.

With that cleared up, let’s look at how Atlas managed its build-time dependencies — starting from the bottom.

Baseline: every service for itself

In the beginning, each service declared its dependencies with explicit versions, copy-pasted from whatever tutorial or sibling service was handy:

<!-- orders-service/pom.xml — the naive baseline -->
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>3.2.1</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <version>3.2.1</version>
  </dependency>
  <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.3</version>
  </dependency>
</dependencies>

This works — until it doesn’t. Two problems show up fast.

Problem 1: transitive conflicts. You didn’t ask for jackson-databind directly at all in most cases; spring-boot-starter-web pulls it in. When you also pin it yourself at a different version, Maven’s “nearest wins” resolution picks one, and it may not be the one either dependency was tested against.

graph TD
  A[orders-service] --> B[spring-boot-starter-web 3.2.1]
  A --> C["jackson-databind 2.15.3 (explicit)"]
  B --> D[jackson-databind 2.16.1]
  C -.->|"nearest wins:<br/>2.15.3 selected"| E{Resolved: 2.15.3}
  D -.->|overridden| E
  style E fill:#ffd9d9,stroke:#c00

The subtle danger: Spring Boot 3.2.1 was integration-tested against Jackson 2.16.1, but your explicit pin silently downgrades it to 2.15.3. Everything compiles. Everything even runs — until some serialization edge case behaves differently in production.

Problem 2: version drift. Multiply the POM above across 14 services, each edited by different people at different times, and you get exactly the situation that made Log4Shell Friday so painful: no single source of truth for “what version of X are we on.”

The root cause is that each service is independently deciding versions, and nothing is coordinating them.

Option A: let the starter parent manage versions

Spring Boot’s first answer to this is the spring-boot-starter-parent. You inherit from it, and it brings a curated, mutually-compatible set of dependency versions with it:

<!-- orders-service/pom.xml — inheriting the Spring Boot parent -->
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.2.1</version>
  <relativePath/>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <!-- no version — managed by the parent -->
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <!-- no version — managed by the parent -->
  </dependency>
</dependencies>

Notice what disappeared: the version numbers. The parent’s <dependencyManagement> section declares them, so every dependency Spring knows about is pinned to a version that was tested to work with all the others. Bumping Spring Boot is now a one-line change to the parent version, and Jackson, Tomcat, Hibernate, and hundreds of others move in lockstep.

This is a genuine leap. Transitive conflicts largely vanish because the whole tree is governed by one curated version set. So why not stop here?

The catch: you only get one parent. Maven inheritance is single-parent. In a real organization, Atlas wants its own parent POM too — for shared build plugin config, common internal dependencies, standardized Java version, and so on. The moment you write:

<parent>
  <groupId>com.atlas.platform</groupId>
  <artifactId>atlas-parent</artifactId>
  <version>5.4.0</version>
</parent>

…you’ve spent your one inheritance slot, and you can no longer inherit from spring-boot-starter-parent. You need a way to get Spring Boot’s managed versions without using up the parent slot.

Option B: import the BOM instead of inheriting it

This is where the BOM — Bill of Materials — enters. A BOM is a POM whose only job is to declare managed dependency versions. Crucially, you can import one into your <dependencyManagement> section without inheriting from it:

<!-- orders-service/pom.xml — company parent + imported Spring Boot BOM -->
<parent>
  <groupId>com.atlas.platform</groupId>
  <artifactId>atlas-parent</artifactId>
  <version>5.4.0</version>
</parent>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>3.2.1</version>
      <type>pom</type>
      <scope>import</scope>   <!-- the magic: import, not inherit -->
    </dependency>
  </dependencies>
</dependencyManagement>

spring-boot-dependencies is the BOM that the starter-parent uses under the hood. By importing it with <scope>import</scope>, we get all of Spring Boot’s curated versions and keep our single inheritance slot free for the company parent. Inheritance and BOM import compose where two parents never could.

graph LR
  subgraph Inheritance["Single inheritance slot"]
      S[orders-service] -->|extends| P[atlas-parent]
  end
  subgraph Import["Composable imports"]
      S -.->|imports BOM| SB[spring-boot-dependencies]
      S -.->|imports BOM| ATLAS[atlas-platform-bom]
  end
  style P fill:#d9e8ff,stroke:#369
  style SB fill:#d9ffe0,stroke:#3a3
  style ATLAS fill:#d9ffe0,stroke:#3a3

This is the mechanism that unlocks everything else. Now let’s zoom out from one service to fourteen — because that’s where the real design decisions live.

Scaling to 14 services: the governance problem

A BOM import fixes version chaos within a service. But Atlas’s Friday-afternoon pain was across services. Two more issues emerge at fleet scale.

Version drift across services. Even with each service importing the Spring Boot BOM, nothing stops orders-service from importing version 3.2.1 while payments imports 3.1.4. Multiply across 14 services and you’re back to “what version are we actually on?” — just one level up.

Shared internal libraries. Atlas has a couple of libraries every service depends on: atlas-common (shared DTOs, error handling) and atlas-security (auth filters). These have their own versions, and their own transitive dependencies, which can conflict with the Spring-managed ones.

The pattern that addresses both is a company platform BOM — an internally published BOM that itself imports the Spring Boot BOM, adds Atlas’s internal library versions, and pins any deliberate overrides. Every service imports one thing:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.atlas.platform</groupId>
      <artifactId>atlas-platform-bom</artifactId>
      <version>2026.1.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
graph TD
  SBOM[spring-boot-dependencies BOM] --> PBOM[atlas-platform-bom 2026.1.0]
  COMMON[atlas-common 4.2.0] --> PBOM
  SEC[atlas-security 2.8.1] --> PBOM
  PBOM --> S1[orders-service]
  PBOM --> S2[payments-service]
  PBOM --> S3[inventory-service]
  PBOM --> S4[notifications-service]
  PBOM --> S5[... 10 more services]
  style PBOM fill:#d9e8ff,stroke:#369,stroke-width:2px
  style SBOM fill:#d9ffe0,stroke:#3a3

Now rewind to that Log4Shell Friday. With a platform BOM in place, the answer to “how many files do we touch?” becomes: one. Bump the Log4j version in atlas-platform-bom, publish 2026.1.1, and each service picks it up on its next build with a single-line version bump — or automatically, if you let a bot raise those PRs. The spreadsheet is gone.

The trade-off nobody tells you about

Here’s where I want to resist the urge to declare “company BOM = best practice” and walk away. Centralized governance has a real cost, and pretending otherwise is how you build a platform team that everyone routes around.

The healthy version of this pattern keeps three things true:

  1. Overrides are allowed and cheap. A service can pin a newer version in its own <dependencyManagement> when it has a real reason — the BOM sets the default, not a prison.
  2. The BOM is versioned and released like a product, on a predictable cadence, with a changelog. A calendar-versioned scheme like 2026.1.0 makes “how stale is this service?” answerable at a glance.
  3. Bumping is automated. A dependency bot raising per-service PRs against new BOM releases is what turns the mechanism into an actual reduction in toil.

Skip these and the BOM becomes a bottleneck: teams pin around it, drift returns, and you’ve added ceremony without the payoff.

When this is overkill

Two microservices and a shared library? You almost certainly don’t need a platform BOM. Inherit spring-boot-starter-parent directly (Option A), or import the Spring Boot BOM if you already have a company parent (Option B), and move on. The governance layer earns its keep somewhere around the point where “how many services are on the old version?” stops being a question you can answer from memory — for us, that was well before 14.

Takeaways

A decision checklist, from least to most machinery — pick the lowest tier that solves your actual problem:

If you… Use
Have one service, no company-wide build config spring-boot-starter-parent (inherit)
Need a company parent POM too Import the spring-boot-dependencies BOM
Run many services + shared internal libraries Publish a company platform BOM that imports Spring Boot’s
Want fleet-wide patching to be a non-event Platform BOM + automated dependency PRs + a release cadence

The mechanism (BOMs) is easy. The design judgment is knowing how much governance your fleet actually needs, and being honest that every layer of centralization you add trades some team autonomy for fleet-wide consistency. And none of it makes you immune to the next Log4Shell — the flaw will still land on your classpath. What it buys you is the thing that actually mattered that Friday: the ability to answer “where are we exposed?” in minutes, and to fix it in one place instead of fourteen. That’s not a heroic weekend — it’s a small, knowable blast radius, and it’s a version strategy worth growing into one service before you think you need it.


Next up: the run-time half of the story — Dependency Injection in Spring Boot: Why Constructor Injection Won.*