Field Guide
DARK MODE

Maven / Gradle

// build lifecycle · dependency management · plugins · multi-module · caching · senior → principal

Overview
Deep Dive
Q & A
Scenarios
Core Concepts
🔄 Maven Build Lifecycle
Maven organizes builds into three built-in lifecycles: default (build and deploy), clean (remove previous build outputs), site (generate project docs). The default lifecycle has 23 phases; the key ones are: validatecompiletestpackageverifyinstalldeploy. Running mvn package executes all phases up to and including package. Plugins bind their goals to phases: maven-compiler-plugin:compile binds to the compile phase automatically. Key insight: you don't call plugin goals directly in CI — you call lifecycle phases. The phases invoke the bound goals. Skip phases when needed: mvn package -DskipTests skips test execution but still compiles tests. -Dmaven.test.skip=true skips both compilation and execution of tests.
validate → compile → test → package phases invoke goals -DskipTests
📦 Maven Dependency Management
Dependencies declared in <dependencies> are resolved from repositories (Maven Central, Nexus, Artifactory) and cached in ~/.m2/repository. Scopes: compile (default — on all classpaths), provided (compile + test only, not packaged — e.g., servlet-api), runtime (runtime + test, not compile — JDBC driver), test (test compile + runtime only — JUnit), system (explicit path, avoid). Transitive dependencies: Maven resolves the full dependency graph. Conflicts use nearest-wins (shallowest in the tree wins). Override with <dependencyManagement> to pin versions centrally. Use mvn dependency:tree to visualize the full graph and find conflicting/unexpected transitive deps. BOM (Bill of Materials): a POM with <dependencyManagement> only — import it with <scope>import</scope> to inherit consistent versions across a suite. Spring Boot BOM is the canonical example.
nearest-wins conflict BOM for version sets mvn dependency:tree
🏗️ Maven Multi-Module Projects
A multi-module project has a parent POM listing <modules>. The parent defines shared config (<dependencyManagement>, <pluginManagement>, <properties>). Child modules inherit from the parent via <parent>. Reactor build order: Maven resolves inter-module dependencies and builds in the correct order. mvn install -pl orders-service -am builds only orders-service and its dependencies (-am = also-make). -amd builds everything that depends on the changed module (also-make-dependents). Aggregator vs inheritance: parent POM can be both an aggregator (listing modules) and a parent (declaring shared config). Keep them separate in large projects: a root aggregator POM + a separate company-parent POM for shared config. Common pitfall: putting application code in the parent module. The parent should contain only build config — never application code.
reactor build order -pl -am for partial build no code in parent
⚡ Gradle Build Basics
Gradle uses a DAG of tasks (not a fixed lifecycle). Each task declares inputs and outputs; Gradle runs only tasks whose inputs have changed (incremental build). Build scripts in Kotlin DSL (.gradle.kts) or Groovy (.gradle). Kotlin DSL is type-safe and IDE-friendly — preferred for new projects. Key commands: ./gradlew build (compile + test + assemble), ./gradlew test, ./gradlew clean build, ./gradlew tasks (list available tasks), ./gradlew dependencies (dependency graph), ./gradlew :subproject:test (run task in specific subproject). Gradle Wrapper: gradlew / gradlew.bat scripts download and cache the Gradle distribution version pinned in gradle/wrapper/gradle-wrapper.properties. Always commit the wrapper — CI uses the same Gradle version as developers.
task DAG not lifecycle incremental by default always use wrapper
💾 Gradle Caching
Gradle has two levels of caching: incremental build (local — task outputs cached on disk; skipped if inputs unchanged) and build cache (local + remote — task outputs identified by input hash; shared across machines and CI workers). Local build cache: enabled by default. ~/.gradle/caches/. Tasks marked @CacheTask have their outputs stored keyed by input hash. Remote build cache: shared across all CI runners and developer machines. Setup: add to settings.gradle.kts: kotlin buildCache { remote<HttpBuildCache> { url = uri("https://cache.company.com/") } } Cache server options: Gradle Enterprise (now Develocity), Gradle Cache Node, Hazelcast. Configuration cache: (Gradle 8+) caches the result of build script evaluation. First run serializes task graph; subsequent runs skip script evaluation. 30–80% faster cold starts. Enable: org.gradle.configuration-cache=true.
build cache = remote sharing config cache = faster startup Develocity for analytics
🔌 Plugins
Maven plugins: bound to lifecycle phases via <executions>. Core plugins (compiler, surefire, jar) are automatically configured. Third-party plugins added in <build><plugins>. <pluginManagement> in parent centralizes version pinning. Key Maven plugins: maven-compiler-plugin (Java version, annotation processors), maven-surefire-plugin (unit tests), maven-failsafe-plugin (integration tests, runs in verify phase), maven-shade-plugin (fat JAR / uber-JAR), spring-boot-maven-plugin (executable JAR, run, build-image). Gradle plugins: applied in plugins {} block. Core plugins (java, application, java-library) add standard tasks. Community plugins from Gradle Plugin Portal. java-library vs java: java-library exposes api and implementation dependency configurations — api dependencies are on the compile classpath of dependents; implementation are not (better compile isolation).
pluginManagement for versions failsafe for ITs api vs implementation
🔒 Dependency Version Control
Maven: use <dependencyManagement> in the parent POM to pin versions. Child modules declare dependencies without versions — they inherit from the parent. Import external BOMs with <scope>import</scope>. Use Maven Versions Plugin (mvn versions:display-dependency-updates) to find outdated dependencies. Gradle: use version catalogs (gradle/libs.versions.toml) — centralized version definitions, type-safe accessor in build scripts: toml [versions] spring-boot = "3.3.1" [libraries] spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" } Reference in build: implementation(libs.spring.boot.starter). Lock files: ./gradlew dependencies --write-locks generates *.lockfile — pins exact transitive versions for reproducible builds. Commit lockfiles for applications (not libraries, which should stay flexible).
version catalog TOML lockfiles for apps BOM imports
📤 Publishing Artifacts
Maven: mvn deploy uploads the JAR + POM + sources + javadoc to the remote repository (Nexus, Artifactory, GitHub Packages). Configure <distributionManagement> with snapshot and release repo URLs. SNAPSHOT versions always publish; release versions are immutable once deployed. Gradle: maven-publish plugin adds publishToMavenLocal and publish tasks. Configure publications and repositories in publishing {} block. Supports publishing to multiple repositories (Nexus for internal, Maven Central for OSS). Signing: Maven Central requires GPG-signed artifacts. maven-gpg-plugin or signing Gradle plugin. CI: store GPG key in secrets, configure via environment. Version strategy: use SNAPSHOT / -SNAPSHOT during development for mutable builds; remove -SNAPSHOT for release artifacts. Semantic versioning (MAJOR.MINOR.PATCH). Automate version bumping with maven-release-plugin or Gradle Axion Release plugin.
SNAPSHOT = mutable release = immutable GPG for Maven Central
Gotchas & Failure Modes
Dependency version conflicts and nearest-wins confusion Maven's nearest-wins rule resolves the same dependency appearing at different versions by picking the shallowest declaration in the tree. This silently downgrades a transitive dependency. Always run mvn dependency:tree after adding dependencies. Gradle fails on conflicts by default — configure resolution strategy if needed. In Gradle: configurations.all { resolutionStrategy.failOnVersionConflict() }.
Using SNAPSHOT dependencies in production SNAPSHOT artifacts are mutable — the same version string (1.0.0-SNAPSHOT) can resolve to different binaries at different times. Never use SNAPSHOT dependencies in released or deployed artifacts. CI pipelines should use release versions only. Maven checks for updated SNAPSHOTs every build by default (slow); suppress with -o (offline mode) or -nsu (no snapshot updates) in CI when SNAPSHOTs aren't needed.
Fat JAR with conflicting resources When using maven-shade-plugin or Gradle Shadow Plugin to create an uber-JAR, multiple JARs may contain the same file (e.g., META-INF/services/... SPI files, spring.factories). The default behavior is last-write-wins — some services will silently disappear. Configure ServicesResourceTransformer and AppendingTransformer in the shade plugin to merge these files correctly.
Ignoring the Gradle wrapper version Running gradle directly uses whatever Gradle is installed on the machine — different from what's in gradle-wrapper.properties. Always use ./gradlew. In CI, never install Gradle globally; use the wrapper. Update the wrapper version deliberately with ./gradlew wrapper --gradle-version 8.8.
Slow builds from unnecessary task re-execution Gradle's incremental build only works if tasks correctly declare all inputs and outputs. Custom tasks that read files without declaring them as @InputFiles always run. Use --dry-run to see which tasks would execute and --profile to generate a build scan showing time per task. Unexpectedly-running UP-TO-DATE checks indicate missing input declarations.
When to Use / When Not To
✓ Use Maven / Gradle When
  • Maven: projects requiring strict convention-over-configuration, XML-auditable builds, or heavy Maven Central publishing
  • Gradle: projects needing build logic customization, fast incremental builds, polyglot builds (Java + Kotlin + Android)
  • Multi-module monorepos where selective building (only changed modules) reduces CI time
  • Any JVM-based project — both tools provide far better dependency management than manual JAR management
✗ Don't Use Maven / Gradle When
  • Non-JVM projects — use npm/pnpm (Node), pip/Poetry (Python), Cargo (Rust), Go modules instead
  • Simple scripts or tools where a plain Makefile or shell script is clearer and has no dependencies
  • Projects where build reproducibility requirements exceed what Maven/Gradle guarantee — consider Bazel or Buck for hermetic builds
Quick Reference & Comparisons
Maven Lifecycle Phases (default)
validateValidate POM and project structure is correct.
compileCompile main sources (src/main/java). Output: target/classes.
test-compileCompile test sources (src/test/java). Output: target/test-classes.
testRun unit tests (Surefire). Build fails if tests fail.
packagePackage compiled code into JAR/WAR/EAR. Output: target/*.jar.
verifyRun integration tests (Failsafe) and quality checks. verify ≠ test.
installInstall artifact to local ~/.m2 repository. Available to other local projects.
deployUpload artifact to remote repository (Nexus, Artifactory). Requires credentials.
Maven Dependency Scopes
compile (default)Available in compile, test, and runtime classpaths. Included in packaged artifact.
providedAvailable in compile + test classpaths. NOT included in package. Container provides at runtime (e.g., servlet-api in WAR → Tomcat provides it).
runtimeNOT available at compile time. Available at test and runtime. Use for: JDBC drivers, SLF4J bindings.
testOnly available in test compile and test runtime. Not in the packaged artifact. JUnit, Mockito, Testcontainers.
importOnly in dependencyManagement. Imports another BOM's dependencyManagement section.
Gradle Dependency Configurations
implementationCompile + runtime classpath. NOT exposed to consumers. Use for most dependencies. Avoids leaking transitive deps to consumers.
apiCompile + runtime, AND exposed to consumers' compile classpath. Use when dependency types appear in your public API. Requires java-library plugin.
compileOnlyCompile classpath only. Not in runtime or consumer compile. Use for: annotation processors, provided dependencies (servlet-api).
runtimeOnlyRuntime classpath only. Not compile. Use for: JDBC drivers, SLF4J bindings, logging implementations.
testImplementationTest compile + runtime. Not in main compile or runtime.
testRuntimeOnlyTest runtime only. JUnit platform launcher, Logback test config.
annotationProcessorAnnotation processors run at compile time. Lombok, MapStruct, Dagger.
Common Maven Commands
mvn clean installClean output, compile, test, package, install to local repo. Standard full build.
mvn package -DskipTestsBuild JAR without running tests (but still compiles test sources).
mvn verify -PfailsafeRun integration tests (Failsafe plugin in verify phase).
mvn dependency:treePrint full dependency graph including transitives. Add -Dincludes=group:artifact to filter.
mvn versions:display-dependency-updatesShow available version updates for all dependencies.
mvn help:effective-pomPrint the fully resolved POM including inherited properties and plugin config.
mvn -pl orders-service -am installBuild orders-service and all modules it depends on (-am = also-make).
mvn -T 4 installParallel build using 4 threads. Use -T 1C for 1 thread per CPU core.
Common Gradle Commands
./gradlew buildCompile, test, assemble. Standard full build.
./gradlew testRun tests only. Incremental: only re-runs if source or test files changed.
./gradlew :subproject:testRun test task in a specific subproject.
./gradlew dependencies --configuration runtimeClasspathShow dependency tree for runtimeClasspath configuration.
./gradlew tasks --allList all available tasks with descriptions.
./gradlew build --scanBuild and upload a build scan to scans.gradle.com. Detailed performance and cache analytics.
./gradlew build --build-cacheEnable build cache (also set in gradle.properties: org.gradle.caching=true).
./gradlew dependencyUpdatesReport outdated dependencies (requires ben-manes/versions plugin).
Maven vs Gradle
Build script XML (pom.xml) — verbose but predictable Kotlin/Groovy DSL — concise, programmable
Build model Fixed lifecycle (phases → goals) Flexible task DAG
Incremental build Limited (requires plugins) First-class, all tasks declare I/O
Build cache No built-in remote cache Built-in local + remote cache
Multi-module Reactor builds, -pl -am Composite builds, included builds
Performance Slower (no incremental) Faster (incremental, parallel, cache)
Learning curve Lower (conventions strict) Higher (flexibility = complexity)
IDE support Excellent (IntelliJ, Eclipse) Excellent (IntelliJ, VS Code)
Plugin ecosystem Huge, mature Large, growing
Convention Strong (opinionated) Flexible (can break conventions)
Reproducibility Good (versions in POM) Better (lockfiles + build cache)
Best for Enterprise Java, Maven Central publishing Android, Kotlin, performance-critical monorepos
Interview Q & A
0 / 0 reviewed
Senior Engineer — Execution Depth
S-01 Explain Maven's dependency resolution and how conflicts are resolved. Senior
Maven builds a full dependency graph by resolving each dependency's transitive dependencies recursively. When the same artifact (same groupId:artifactId) appears at multiple versions in the graph, Maven applies two rules: Nearest-wins: the version declared closest to the root project in the dependency tree wins. project → A (1.0) → C (1.5) and project → B (1.0) → C (1.2): if A and B are both direct dependencies (depth 1), C is at depth 2 from both — first declaration in the POM wins (A's path → C 1.5). First-declaration wins (tiebreaker at same depth): the first dependency that brings in a given transitive dep wins. Problems: nearest-wins can silently downgrade a library. If C 1.5 has a security fix and nearest-wins picks C 1.2, your build is vulnerable without any warning. Fix with <dependencyManagement>: Explicitly pin the version of any transitive dependency you care about: xml <dependencyManagement> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>C</artifactId> <version>1.5</version> </dependency> </dependencies> </dependencyManagement> This wins over all transitive declarations. Gradle: fails on version conflict by default with a clear error. Resolve with resolutionStrategy.force("com.example:C:1.5").
S-02 What is the difference between maven-surefire-plugin and maven-failsafe-plugin? Senior

Both run tests, but at different lifecycle phases with different failure semantics: maven-surefire-plugin (unit tests): - Binds to the test phase - By convention, runs classes matching **/*Test.java, **/*Tests.java, **/*TestCase.java - If tests fail, the build fails immediately in the test phase - Does NOT start/stop server infrastructure maven-failsafe-plugin (integration tests): - Binds goals to pre-integration-test, integration-test, and post-integration-test phases (within verify) - By convention, runs classes matching **/*IT.java, **/*ITCase.java - Critical difference: even if integration tests FAIL, the build continues through post-integration-test (allowing cleanup of started servers/containers), then fails in the verify phase - Works with build-helper-maven-plugin to start Testcontainers or embedded servers in pre-integration-test and stop them in post-integration-test

In practice: mvn test runs unit tests. mvn verify runs unit tests + integration tests. CI pipelines typically run mvn verify for full coverage. Separate them to allow fast feedback: mvn test first, then mvn verify -DskipTests (skip unit tests, run only integration tests) in a later CI step.

S-03 How does Gradle's incremental build work? What can break it? Senior
Gradle marks a task as UP-TO-DATE if none of its declared inputs have changed since the last execution and all declared outputs still exist. If UP-TO-DATE, the task is skipped entirely. How inputs/outputs are declared: - @Input: scalar values (String, Boolean, Enum). Changes trigger re-run. - @InputFile / @InputFiles / @InputDirectory: file content. SHA checksums compared. - @OutputFile / @OutputFiles / @OutputDirectory: expected output files. - @Internal: fields not affecting outputs (e.g., a logger). Not tracked. What breaks incremental builds: 1. Undeclared inputs: a task reads System.getProperty("user.name") but doesn't declare it as @Input. Gradle doesn't know to re-run when the user changes. Result: task stays UP-TO-DATE when it shouldn't. 2. Side effects outside declared outputs: task writes to a file not in @OutputFiles. Another task reading that file won't know the first task changed it. 3. Non-deterministic outputs: tasks that produce different outputs for the same inputs (timestamps in JAR manifests, random ordering) break the build cache (different cache key each run). 4. doLast blocks reading undeclared inputs. Debug: ./gradlew <task> --info shows why a task was not UP-TO-DATE.
S-04 How would you optimize a slow Maven/Gradle build in CI? Senior

Step 1: Profile to find the bottleneck. Maven: mvn install -fae + mvn -Dsurefire.reportFormat=plain verify. Check which phase takes longest. Gradle: ./gradlew build --scan → upload build scan → detailed task timeline. Gradle optimizations: - Enable build cache: org.gradle.caching=true in gradle.properties + remote cache server. CI workers share cached task outputs — if another worker already built the same inputs, the task is fetched from cache (from remote cache = CACHE HIT, skip execution). - Configuration cache: org.gradle.configuration-cache=true. Caches task graph; configuration phase (executing build scripts) is skipped on subsequent runs. - Parallel execution: org.gradle.parallel=true. Independent subproject tasks run in parallel. - Selective builds: in a monorepo, only build modules affected by the change. Use ./gradlew :orders-service:build or a plugin (Gradle Affected Modules).

Maven optimizations: - Parallel: mvn -T 1C install (one thread per CPU core). - Selective builds: -pl changed-module -am (only changed module + dependencies). - Skip unnecessary phases in CI: mvn package -DskipTests for the build step; separate mvn test step. Use -o (offline) when SNAPSHOTs aren't needed. - Daemon: Maven daemon (mvnd) caches the JVM across invocations — significant startup overhead reduction.

Common culprit: network I/O. Use a local Nexus/Artifactory mirror; CI should have a warm dependency cache (mount ~/.m2 or ~/.gradle from a cache volume).

S-05 How do you manage dependency versions across a large multi-module project? Senior
Maven approach: Define all dependency versions in the root parent POM <dependencyManagement>. Child modules declare dependencies WITHOUT <version> — they inherit the version from the parent. This ensures all modules use consistent versions. For third-party library suites (Spring Boot, Spring Cloud, AWS SDK), import their BOMs: xml <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>3.3.1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> Now no child needs to specify Spring Boot dependency versions — the BOM covers them all. Gradle approach: Use gradle/libs.versions.toml (version catalog): toml [versions] spring-boot = "3.3.1" [libraries] spring-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } Reference: implementation(libs.spring.starter.web). Type-safe, IDE-autocompleted, centralized. Apply as a platform: implementation(platform(libs.spring.bom)). Dependency updates: automate with Dependabot (GitHub) or Renovate Bot — opens PRs when new versions are available, includes changelog links.
S-06 What is the difference between 'api' and 'implementation' in Gradle's java-library plugin? Senior
The distinction controls what is exposed on the compile classpath of consumers (modules or projects that depend on your library). implementation: the dependency is used internally. It goes on your compile classpath AND your runtime classpath, but it does NOT go on the compile classpath of modules that depend on you. api: the dependency types appear in your public API (method signatures, return types, field types). It goes on your compile classpath, your runtime classpath, AND the compile classpath of all consumers. Why it matters: kotlin // library-a/build.gradle.kts dependencies { implementation("com.google.guava:guava:32.0-jre") // consumers can't use Guava types api("org.slf4j:slf4j-api:2.0.9") // consumers can use Logger directly } Using implementation where possible: (1) consumers don't accidentally depend on your internal libraries, (2) Gradle can skip recompiling consumers when only an implementation dependency changes (ABI hasn't changed), (3) cleaner dependency graph. Rule of thumb: use implementation unless the type literally appears in your public method signatures or fields. Default to implementation.
S-07 How do you create and publish a Maven library to Maven Central? Senior

Maven Central (via Sonatype) has strict requirements: - Source JAR (-sources.jar) and Javadoc JAR (-javadoc.jar) must be published - All artifacts must be GPG-signed - POM must include: <name>, <description>, <url>, <licenses>, <developers>, <scm> elements

POM configuration: xml <build><plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-source-plugin</artifactId> <executions><execution><goals><goal>jar-no-fork</goal></goals></execution></executions> </plugin> <plugin> <artifactId>maven-javadoc-plugin</artifactId> <executions><execution><goals><goal>jar</goal></goals></execution></executions> </plugin> <plugin> <artifactId>maven-gpg-plugin</artifactId> <executions><execution><goals><goal>sign</goal></goals></execution></executions> </plugin> </plugins></build> Publish workflow: 1. Deploy SNAPSHOT to Sonatype OSS snapshot repo 2. Set release version (remove -SNAPSHOT) 3. mvn clean deploy -Prelease → uploads to Sonatype staging 4. Log into Sonatype Nexus UI → close staging repo → release 5. Syncs to Maven Central within ~30 minutes Modern alternative: use the Central Publishing Portal API or the central-publishing-maven-plugin for a simpler automated workflow.

Staff Engineer — Design & Cross-System Thinking
ST-01 How do you structure a Gradle multi-module monorepo build for 30 services to minimize CI build time? Staff
Problem: 30 services in a monorepo. Running ./gradlew build for all 30 takes 30 minutes. Most commits touch 1–2 services. Solution: affected-module detection + remote build cache Step 1: Remote build cache. All CI runners and developers share a Gradle remote cache (Develocity or self-hosted). org.gradle.caching=true. After the first full build, subsequent builds hit the cache for unchanged modules. CI runner building service-A after another runner already built the same inputs → CACHE HIT, no compilation needed. Step 2: Detect affected modules. Use git diff --name-only origin/main...HEAD to find changed files. Map file paths to Gradle subprojects (:orders-service, :shared-lib). Run only affected subprojects and their dependents: bash ./gradlew :orders-service:build :orders-service:test -amd -amd = also-make-dependents: builds any service that depends on orders-service. Catches regression in consumers of a changed shared library. Step 3: Composite builds for shared libraries. Extract truly shared code (common-lib) into a separate Gradle composite build. It's built independently; services include it. Changes to common-lib trigger rebuilds of all dependent services (via -amd); changes to one service don't trigger common-lib rebuild. Step 4: Configuration cache. org.gradle.configuration-cache=true. Caches the task graph configuration. Build startup drops from 30s to 5s for cached configurations. Result: a PR touching orders-service: builds 1–3 affected modules instead of 30. Cache hit on unchanged modules. Total CI time: 3 min instead of 30 min.
ST-02 How do you enforce dependency governance (approved libraries, banned transitive deps, license compliance) in a Maven/Gradle build? Staff
Maven: maven-enforcer-plugin xml <plugin> <artifactId>maven-enforcer-plugin</artifactId> <executions><execution><goals><goal>enforce</goal></goals> <configuration><rules> <bannedDependencies> <excludes> <exclude>commons-logging:commons-logging</exclude> <exclude>log4j:log4j</exclude> </excludes> </bannedDependencies> <requireUpperBoundDeps/> <!-- warn when lower version wins conflict --> <dependencyConvergence/> <!-- fail on any version conflict --> </rules></configuration> </execution></executions> </plugin> Gradle: dependency constraints + resolution rules kotlin configurations.all { resolutionStrategy { failOnVersionConflict() force("org.slf4j:slf4j-api:2.0.9") eachDependency { if (requested.group == "commons-logging") { useTarget("org.slf4j:jcl-over-slf4j:2.0.9") because("Replace commons-logging with SLF4J bridge") } } } } License compliance: - Maven: license-maven-plugin generates license reports; fail on prohibited licenses (GPL in a commercial product) - Gradle: com.github.jk1.dependency-license-report plugin; configure allowed/forbidden licenses OWASP CVE scanning: - Maven: org.owasp:dependency-check-mavenmvn verify -Pcheck fails on CVSS > 7.0 - Gradle: org.owasp.dependencycheck plugin - CI: run on every PR; block merge on HIGH/CRITICAL CVEs
Principal Engineer — Architecture & Org-Scale Thinking
P-01 How do you design a build platform strategy for an enterprise with 200 repositories and 500 engineers? Principal
The core problems at scale: - Inconsistent build tooling versions across teams (Maven 3.6 vs 3.9, Java 11 vs 21) - Duplicated build logic (every team writes their own Docker build, GPG signing, Sonar integration) - Slow CI because no shared build cache - Security: old dependencies with CVEs, no license governance Centralized parent POM / convention plugin: Publish a company-parent POM (Maven) or company-conventions Gradle plugin. It encodes: Java version, compiler settings, Checkstyle config, Sonar integration, OWASP dependency check, Enforcer rules (ban old logging frameworks), publishing config. Teams inherit without configuring. Update the parent → all teams get the update on next build. Artifact repository governance (Nexus/Artifactory): - Proxy Maven Central through the internal Nexus — all dependencies go via internal mirror - Curated "approved" group for internal artifacts - Automated CVE scanning on Nexus: quarantine artifacts with CRITICAL CVEs before teams can even download them - License compliance enforced at the proxy layer Shared build cache: Gradle Develocity (formerly Gradle Enterprise) or Gradle Cache Node: - All CI runners push to and pull from shared cache - Developers pull from shared cache — first local build after CI is mostly cache hits - Build scan analytics: which tasks are slowest, which teams have most cache misses Toolchain standardization: - Maven Toolchains or Gradle toolchain support: declare java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } - JDK auto-provisioned by Gradle; no "Java version mismatch" bugs - Foojay toolchain resolver downloads the right JDK automatically Dependency updates automation: - Renovate Bot running org-wide: opens PRs in all repos when deps have new versions - Priority bot: flag CVE-driven updates with P1 label; auto-assign to owning team
System Design Scenarios
Reduce CI Build Time from 45 Minutes to Under 10 Minutes
Problem
A Spring Boot monorepo with 12 modules takes 45 minutes to build in CI. Developer feedback loops are painful. The build runs mvn clean install on every commit to every branch. Tests account for 35 of the 45 minutes.
Constraints
  • GitHub Actions CI with ephemeral runners (no persistent disk)
  • Maven build, Java 21
  • Cannot remove any tests — coverage is a compliance requirement
  • 3 frontend engineers who don't understand the build tooling deeply
Key Discussion Points
  • Migrate to Gradle for incremental builds and remote cache: While Maven can be optimized, Gradle's incremental build + remote build cache provides the largest gains. If migration is too costly, proceed with Maven optimizations.
  • Maven parallel execution: mvn -T 1C install runs independent modules in parallel (one thread per CPU core). GitHub Actions ubuntu-latest has 2 cores → 2x speedup for independent modules. Add -fae (fail at end) to complete parallel build even if one module fails.
  • Selective module builds: Use a GitHub Actions matrix to detect which modules changed. Build only affected modules using mvn -pl changed-module1,changed-module2 -am install. For PRs touching 1–2 modules: build time drops to 5–15 minutes.
  • Split unit and integration tests: Configure Failsafe for integration tests. In CI: Step 1: mvn test -T 1C (fast, parallel unit tests, ~5 min). Step 2: mvn verify -DskipUnitTests -T 1C (only integration tests). Run in parallel GitHub Actions jobs → total wall time = max(unit, IT).
  • GitHub Actions cache for Maven repo: yaml - uses: actions/cache@v4 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-maven- Dependency download time: 10 min → 30 seconds on cache hit.
🚩 Red Flags
  • mvn clean on every build — clean removes all compiled classes and restarts from scratch; omit clean unless necessary
  • Running all tests in a single sequential job — parallelism across jobs is free in GitHub Actions
  • No dependency cache in CI — re-downloading hundreds of MB on every ephemeral runner
  • Mixing unit and integration tests in the same phase — can't run them independently or in parallel
Resolve a Transitive Dependency Conflict Causing Runtime Failures
Problem
A Spring Boot application starts failing in production with NoSuchMethodError on a method from jackson-databind. The method exists in Jackson 2.15 but was removed in 2.16. mvn dependency:tree shows both versions in the dependency graph.
Constraints
  • Spring Boot 3.2 (manages Jackson 2.16)
  • A third-party library `analytics-sdk:2.1` depends on Jackson 2.15
  • Cannot upgrade analytics-sdk (no newer version available)
Key Discussion Points
  • Diagnose with dependency:tree: Run mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind. Output shows: Spring Boot BOM wants 2.16.1; analytics-sdk:2.1 → jackson-databind:2.15.3. Maven nearest-wins picked 2.15.3 (analytics-sdk is at depth 2, Spring Boot BOM at depth 1). Actually Spring Boot BOM is in <dependencyManagement> — it doesn't add a physical node, so analytics-sdk's transitive dep (depth 2) wins over nothing in the dependency tree.
  • Fix: explicit dependencyManagement pin: In the root POM <dependencyManagement>, explicitly declare: xml <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.16.1</version> </dependency> <dependencyManagement> declarations always win over transitive declarations. Jackson 2.16.1 is now forced regardless of what analytics-sdk requests.
  • Verify the fix: Re-run mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind. Confirm only 2.16.1 appears. Run mvn verify — tests pass. Deploy to staging.
  • Root cause prevention: Add <requireUpperBoundDeps> rule to maven-enforcer-plugin. It warns when a lower version wins a conflict. Would have caught this at build time rather than production. Also add <dependencyConvergence/> to fail the build if any version conflicts exist.
🚩 Red Flags
  • Excluding the transitive dependency entirely without adding the correct version — you end up with no Jackson on the classpath
  • Upgrading analytics-sdk without verifying it's actually compatible with Jackson 2.16
  • Not verifying the fix with dependency:tree after applying — the pin may not have taken effect