skip to content

At org scale, curated starters can pull large transitive graphs (bloat, unwanted CVE-carrying jars, duplicate logging bindings). How do you keep dependency hygiene while still benefiting from starters?

level: principalimportance: nice to knowfreq 25%

answer

  1. BOM for versions, prune the graph
  2. Single internal platform -> one-place CVE bumps
  3. dependency:tree / gradle dependencies audit
  4. Exclude starter-logging when adding log4j2 (dup SLF4J binding)
  5. Auto-config backs off -> omit unneeded starters

basics

~20 s

Keep the version alignment from the BOM but prune what you don't use: exclude unwanted transitive jars (e.g. resolve duplicate logging bindings), audit the resolved graph with dependency:tree / dependencies, enforce via a shared internal BOM/platform, and let auto-config back off for absent classes so unused starters aren't force-added.

solid answer

~40 s

The strategy is: trust the BOM for **versions**, but treat the **transitive graph** as something to review and prune. Concretely: (1) Consolidate version management in a **single internal platform/BOM** that imports `spring-boot-dependencies` so every service inherits the same aligned, vetted versions and CVE patches land in one place. (2) **Audit** what starters actually resolve with `mvn dependency:tree` / `gradle dependencies` and tools like OWASP Dependency-Check; remove starters you don't use rather than carrying them. (3) **Exclude** problematic transitives — the classic case is duplicate SLF4J bindings (exclude `spring-boot-starter-logging` when adding log4j2, or Boot warns/fails on multiple bindings). (4) Rely on **conditional auto-config back-off** (`@ConditionalOnClass`) so an absent library simply doesn't configure. (5) Enforce with build-fail policies (`failOnVersionConflict`, dependency-cruiser-equivalents, `enforcedPlatform`). The goal: keep the curated coherence, drop the incidental bloat.

code

kotlin · 17 lines
kotlin
// Org platform module consumed by every service (one place for versions/CVEs)
// build.gradle.kts of the platform:
plugins { `java-platform` }
dependencies {
    api(platform("org.springframework.boot:spring-boot-dependencies:3.4.1"))
    // org-specific pins/CVE bumps live here, once
    constraints { api("com.fasterxml.jackson.core:jackson-databind:2.17.2") }
}

// A service: enforce the platform + prune a duplicate logging binding
dependencies {
    implementation(enforcedPlatform("com.acme:acme-platform:2025.7"))
    implementation("org.springframework.boot:spring-boot-starter-web") {
        exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
    }
    implementation("org.springframework.boot:spring-boot-starter-log4j2")
}

go deeper

for a junior

Aware starters can bring many jars.

for a middle

Can run dependency:tree and perform a basic exclusion.

for a senior

Manages exclusions (logging/server), scopes, and CVE scanning per service.

for a principal

Owns an org-wide platform/BOM strategy: centralized versions/CVE bumps, enforced convergence, reproducible locks, and policy for surgical exclusions vs blanket inclusion.

## The tension Starters trade **breadth for convenience**: one coordinate can transitively bring dozens of jars. At one service that's fine; across an org it means larger images, wider CVE surface, and occasional conflicts (two logging bindings, two JSON libs). The principal-level skill is keeping the **alignment guarantee** while controlling the **graph**. ## 1. Centralize version management Create an **internal platform/BOM** module that `import`s `spring-boot-dependencies` (and Spring Cloud's BOM, etc.), pins any org-specific overrides once, and is consumed by every service (Maven `import` scope, or Gradle `platform()/enforcedPlatform()`). Benefits: a CVE bump (e.g., a Jackson or Tomcat security release) is made **once** and propagates; all services stay mutually aligned. Avoid per-service version overrides that fragment the matrix. ## 2. Make the resolved graph visible Starters hide the graph; make it explicit: - `mvn dependency:tree` / `./gradlew dependencies` — see what each starter actually pulls. - **OWASP Dependency-Check** / GitHub Dependabot / Snyk — flag CVE-carrying transitives. - Fail the build on **version conflicts** (`enforcedPlatform`, Maven Enforcer `dependencyConvergence`). ## 3. Prune with exclusions — the canonical logging case `spring-boot-starter` brings **`spring-boot-starter-logging`** (Logback + SLF4J bridges). If you want Log4j2, you add `spring-boot-starter-log4j2` **and exclude** `spring-boot-starter-logging` — otherwise you have **two SLF4J bindings** on the classpath, and SLF4J warns/behaves nondeterministically. This is the textbook example of "a curated set needs surgical exclusion": ```kotlin implementation("org.springframework.boot:spring-boot-starter-web") { exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging") } implementation("org.springframework.boot:spring-boot-starter-log4j2") ``` Similar surgical exclusions: dropping `tomcat-embed` for Jetty, removing an unwanted JSON binding, or excluding a transitive that duplicates something you provide. ## 4. Don't over-include starters in the first place The cheapest hygiene is **not adding** starters you don't need. Because auto-config is **conditional** (`@ConditionalOnClass`), a starter you don't include simply never configures — there's no penalty for omission and a real cost (jars + CVE surface + startup class scanning) for inclusion. Split responsibilities: a batch job doesn't need `spring-boot-starter-web`. ## 5. Watch the compile/runtime scope boundary Starters are usually `implementation`/`compile` scope; that leaks their API onto the consumer's compile classpath. Use Gradle's `implementation` (not `api`) in libraries so a starter you depend on internally doesn't transitively become part of your published API surface. ## 6. Reproducibility Use a **dependency lock** (Gradle dependency locking, Maven via the enforcer + fixed versions) so the resolved graph is reproducible build-to-build — starters + ranges can otherwise drift. ## Gotchas / anti-patterns - **Excluding a jar a starter genuinely needs** — you can break auto-config; verify with the tree, not by guessing. - **Local version override to "fix" a CVE** — prefer bumping the platform/BOM so alignment holds; a lone override can desync the tested matrix. - **Adding both Tomcat and Jetty / Logback and Log4j2** — duplicate providers cause nondeterministic selection or startup errors. - **Treating the starter as the API** — publish narrow APIs; keep starters as an internal implementation detail where possible. ## Bottom line Starters + BOM give **coherence**; org-scale hygiene adds **curation on top**: one shared platform for versions/CVEs, visible + policed graphs, and surgical exclusions where the curated default doesn't fit.

  • You switch to Log4j2 but see SLF4J 'multiple bindings' warnings. What went wrong and how do you fix it?
    spring-boot-starter-logging (Logback binding) is still transitively present alongside spring-boot-starter-log4j2, so SLF4J finds two bindings. Exclude spring-boot-starter-logging from the starter(s) that pull it (typically the core spring-boot-starter via your web/data starter) so only the Log4j2 binding remains.
  • A transitive jar from a starter has a CVE. What's the preferred remediation at org scale, and what's the risk of a quick local override?
    Preferred: bump the version once in the shared internal platform/BOM (importing spring-boot-dependencies) so every service inherits the patched, still-aligned version. A one-off local <version> override fixes one service but escapes the tested compatibility matrix and fragments versions across services.

saying these in an interview costs you the question

  • Believing you must accept a starter's entire transitive graph as-is
  • Fixing a CVE with scattered per-service version overrides instead of one shared platform
  • Running both Logback and Log4j2 (or Tomcat and Jetty) without excluding one
  • Excluding a jar the starter needs and breaking auto-config

context