// build lifecycle · dependency management · plugins · multi-module · caching · senior → principal
validate → compile → test → package → verify → install → deploy. 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.
<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.
<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.
.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.
~/.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.
<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).
<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).
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.
mvn dependency:tree after adding dependencies. Gradle fails on conflicts by default — configure resolution strategy if needed. In Gradle: configurations.all { resolutionStrategy.failOnVersionConflict() }.
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.
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.
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.
@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.
| validate | Validate POM and project structure is correct. |
| compile | Compile main sources (src/main/java). Output: target/classes. |
| test-compile | Compile test sources (src/test/java). Output: target/test-classes. |
| test | Run unit tests (Surefire). Build fails if tests fail. |
| package | Package compiled code into JAR/WAR/EAR. Output: target/*.jar. |
| verify | Run integration tests (Failsafe) and quality checks. verify ≠ test. |
| install | Install artifact to local ~/.m2 repository. Available to other local projects. |
| deploy | Upload artifact to remote repository (Nexus, Artifactory). Requires credentials. |
| compile (default) | Available in compile, test, and runtime classpaths. Included in packaged artifact. |
| provided | Available in compile + test classpaths. NOT included in package. Container provides at runtime (e.g., servlet-api in WAR → Tomcat provides it). |
| runtime | NOT available at compile time. Available at test and runtime. Use for: JDBC drivers, SLF4J bindings. |
| test | Only available in test compile and test runtime. Not in the packaged artifact. JUnit, Mockito, Testcontainers. |
| import | Only in dependencyManagement. Imports another BOM's dependencyManagement section. |
| implementation | Compile + runtime classpath. NOT exposed to consumers. Use for most dependencies. Avoids leaking transitive deps to consumers. |
| api | Compile + runtime, AND exposed to consumers' compile classpath. Use when dependency types appear in your public API. Requires java-library plugin. |
| compileOnly | Compile classpath only. Not in runtime or consumer compile. Use for: annotation processors, provided dependencies (servlet-api). |
| runtimeOnly | Runtime classpath only. Not compile. Use for: JDBC drivers, SLF4J bindings, logging implementations. |
| testImplementation | Test compile + runtime. Not in main compile or runtime. |
| testRuntimeOnly | Test runtime only. JUnit platform launcher, Logback test config. |
| annotationProcessor | Annotation processors run at compile time. Lombok, MapStruct, Dagger. |
| mvn clean install | Clean output, compile, test, package, install to local repo. Standard full build. |
| mvn package -DskipTests | Build JAR without running tests (but still compiles test sources). |
| mvn verify -Pfailsafe | Run integration tests (Failsafe plugin in verify phase). |
| mvn dependency:tree | Print full dependency graph including transitives. Add -Dincludes=group:artifact to filter. |
| mvn versions:display-dependency-updates | Show available version updates for all dependencies. |
| mvn help:effective-pom | Print the fully resolved POM including inherited properties and plugin config. |
| mvn -pl orders-service -am install | Build orders-service and all modules it depends on (-am = also-make). |
| mvn -T 4 install | Parallel build using 4 threads. Use -T 1C for 1 thread per CPU core. |
| ./gradlew build | Compile, test, assemble. Standard full build. |
| ./gradlew test | Run tests only. Incremental: only re-runs if source or test files changed. |
| ./gradlew :subproject:test | Run test task in a specific subproject. |
| ./gradlew dependencies --configuration runtimeClasspath | Show dependency tree for runtimeClasspath configuration. |
| ./gradlew tasks --all | List all available tasks with descriptions. |
| ./gradlew build --scan | Build and upload a build scan to scans.gradle.com. Detailed performance and cache analytics. |
| ./gradlew build --build-cache | Enable build cache (also set in gradle.properties: org.gradle.caching=true). |
| ./gradlew dependencyUpdates | Report outdated dependencies (requires ben-manes/versions plugin). |
| 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 |
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").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.
@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.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).
<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.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.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.
./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.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-maven → mvn verify -Pcheck fails on CVSS > 7.0 - Gradle: org.owasp.dependencycheck plugin - CI: run on every PR; block merge on HIGH/CRITICAL CVEscompany-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 teammvn clean install on every commit to every branch. Tests account for 35 of the 45 minutes.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.mvn -pl changed-module1,changed-module2 -am install. For PRs touching 1–2 modules: build time drops to 5–15 minutes.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).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.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.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.<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.mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind. Confirm only 2.16.1 appears. Run mvn verify — tests pass. Deploy to staging.<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.