Maven

The build tool you'll use every day — lifecycle, profiles, and diagnosing dependency conflicts

← Back to Index

What is Maven?

Apache Maven is a build automation and dependency management tool built around a declarative model: instead of writing scripts that say how to build your project, you declare what your project is — its dependencies, its packaging type, its plugins — and Maven executes a standardised lifecycle to do the rest.

The problem it solves: before Maven (and Ant before it had conventions), every project invented its own directory layout and build script. Onboarding a new developer meant learning that specific project's bespoke build process. Maven's convention over configuration means any Java developer who knows Maven can clone a Maven project and immediately know where the source lives, how to build it, and how to run its tests.

// Without Maven: manual JAR management, custom scripts, inconsistent structure
// With Maven: declare what you need in pom.xml, run one command
mvn clean install

Standard Project Structure

my-project/
├── pom.xml                    # Project Object Model — the single source of truth
├── src/
│   ├── main/
│   │   ├── java/               # application source
│   │   └── resources/          # config files, properties, copied to classpath root
│   └── test/
│       ├── java/               # test source
│       └── resources/          # test-only resources
└── target/                    # generated — never commit, always in .gitignore
    ├── classes/
    ├── test-classes/
    └── my-project-1.0.0.jar

Minimal pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>

    <!-- groupId:artifactId:version — uniquely identifies this artifact in any repository -->
    <groupId>com.company</groupId>
    <artifactId>my-app</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>  <!-- jar, war, pom (parent/aggregator) -->

    <properties>
        <java.version>21</java.version>
        <maven.compiler.release>${java.version}</maven.compiler.release>  <!-- prefer 'release' over source/target -->
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.10.0</version>
            <scope>test</scope>  <!-- not bundled in the final jar -->
        </dependency>
    </dependencies>
</project>
Where do all the defaults come from? The Super POM

Notice the minimal POM above never declares src/main/java as the source directory, never says packaging defaults to jar, and never configures Maven Central as a repository. That's because every Maven project implicitly inherits from the Super POM — a built-in parent POM shipped inside the Maven installation itself. Your pom.xml only needs to declare what's different from those defaults.

The Super POM — Where Defaults Come From

Every pom.xml has an implicit parent, even when you never declare one. That parent is the Super POM, bundled with your Maven installation. It defines the baseline that "convention over configuration" actually means in practice.

/*
 *  Effective inheritance chain for ANY Maven project:
 *
 *  Super POM (built into Maven itself)
 *       │  defines: default source dirs, default packaging (jar),
 *       │  default plugin versions, Maven Central as the repository
 *       ▼
 *  Your parent POM(s)         (optional — your own org's parent, or
 *       │                      a BOM like spring-boot-starter-parent)
 *       ▼
 *  Your project's pom.xml     (only overrides what's actually different)
 */

What the Super POM actually declares

// The relevant parts of the Super POM, simplified — this is WHY these
// defaults exist without you ever writing them:

<packaging>jar</packaging>  <!-- omit packaging entirely → you get a jar -->

<build>
    <sourceDirectory>${basedir}/src/main/java</sourceDirectory>
    <testSourceDirectory>${basedir}/src/test/java</testSourceDirectory>
    <outputDirectory>${basedir}/target/classes</outputDirectory>
    <finalName>${artifactId}-${version}</finalName>  <!-- explains the my-app-1.0.0.jar naming -->
</build>

<repositories>
    <repository>
        <id>central</id>
        <url>https://repo.maven.apache.org/maven2</url>  <!-- why Central works with zero config -->
    </repository>
</repositories>
# See the FULL effective POM your project actually builds with —
# Super POM defaults + any parent POM + your own pom.xml, fully merged
mvn help:effective-pom

# This is the single most useful command for understanding "why is Maven
# doing X" when nothing in YOUR pom.xml explains it
Your own parent POM works the same way

When you declare <parent> pointing at your company's shared parent POM, or at spring-boot-starter-parent, you're inserting another layer into this same inheritance chain — between the Super POM and your project. That's precisely how spring-boot-starter-parent gives you sensible default plugin versions and a dependencyManagement BOM without you declaring a single dependency version yourself.

The Build Lifecycle

Maven phases run in a fixed sequence — calling a later phase runs every phase before it automatically. This is why mvn install also compiles and tests: it doesn't skip ahead.

/*
 *  validate → compile → test → package → verify → install → deploy
 *
 *  validate   — project structure is correct
 *  compile    — src/main/java → target/classes
 *  test       — runs src/test/java via Surefire
 *  package    — bundles into jar/war in target/
 *  verify     — runs integration checks (Failsafe, quality gates)
 *  install    — copies the artifact into ~/.m2/repository (local cache)
 *  deploy     — uploads the artifact to a REMOTE repository (Nexus, Artifactory)
 *
 *  Calling 'mvn install' runs: validate → ... → install, in order.
 *  'deploy' is NOT part of install — it's a separate, explicit step,
 *  almost always run only by CI/CD, never from a dev machine.
 */

The commands you'll actually type every day

# The one you run constantly — full clean rebuild, installs to local repo
# so other local modules/projects can depend on it
mvn clean install

# Faster — package without installing to ~/.m2 (most CI build steps)
mvn clean package

# Skip test EXECUTION but still compile them (catches compile errors)
mvn install -DskipTests

# Skip test compilation entirely — only for genuine emergencies
mvn install -Dmaven.test.skip=true

# Run a single test class or method — huge time saver during debugging
mvn test -Dtest=UserServiceTest
mvn test -Dtest=UserServiceTest#shouldRejectInvalidEmail

# Force Maven to re-check remote repos for SNAPSHOT updates
# (without -U, Maven trusts its local cache and may use a stale SNAPSHOT)
mvn clean install -U

# Offline mode — use only the local repository, no network calls
# (fast, and forces you to notice if a new dependency isn't cached yet)
mvn install -o

# Parallel build across modules in a multi-module project — real speedup
# on multi-core machines (T = threads, here 1 per core)
mvn install -T 1C

# Debug output — when a build fails for a non-obvious reason
mvn install -X

# Deploy to the remote repository configured in distributionManagement
# (almost always a CI-only command, never run manually against prod repos)
mvn deploy

Profiles — Environment-Specific Builds

A profile lets you override configuration — dependencies, properties, plugins — based on environment, without maintaining separate POM files. Activated explicitly with -P, or automatically by conditions.

<profiles>
    <profile>
        <id>dev</id>
        <activation>
            <activeByDefault>true</activeByDefault>  <!-- runs if no profile is explicitly requested -->
        </activation>
        <properties>
            <db.url>jdbc:h2:mem:devdb</db.url>
            <log.level>DEBUG</log.level>
        </properties>
    </profile>

    <profile>
        <id>prod</id>
        <properties>
            <db.url>jdbc:postgresql://prod-db:5432/app</db.url>
            <log.level>WARN</log.level>
        </properties>
        <build>
            <plugins>
                <!-- e.g. skip dev-only tools, enable stricter checks -->
            </plugins>
        </build>
    </profile>

    <profile>
        <id>ci</idactivation>
            <property><name>env.CI</name></property>  <!-- auto-activates if CI env var is set -->
        </activation>
    </profile>
</profiles>
# Activate a profile explicitly — this is your everyday dev workflow command
mvn clean install -Pdev

# Multiple profiles, comma-separated
mvn package -Pprod,skip-integration-tests

# Deactivate a profile that would otherwise auto-activate
mvn package -P-dev,prod

# Check which profiles are currently active for the build
mvn help:active-profiles
Real pattern: profile per deployment target

A very common setup: local/dev profile activated by default for IDE/local work, staging and prod profiles activated explicitly in CI/CD pipeline steps (mvn deploy -Pprod), each overriding the distributionManagement target repository and environment properties. Combined with Spring profiles (spring.profiles.active), this gives you a clean separation between "which artifact am I building" (Maven) and "which config does it load at runtime" (Spring).

Diagnosing Dependency Conflicts

This is the skill that separates someone who uses Maven from someone who understands it. Most "works on my machine" build failures and runtime NoSuchMethodErrors trace back to a dependency conflict that dependency:tree would have caught.

mvn dependency:tree — find what's pulling in what

# Print the full dependency tree, including transitive dependencies
mvn dependency:tree

# Output looks like:
[INFO] com.company:my-app:jar:1.0.0
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.2.0:compile
[INFO] |  +- org.springframework:spring-web:jar:6.1.0:compile
[INFO] |  \- com.fasterxml.jackson.core:jackson-databind:jar:2.15.3:compile
[INFO] +- com.legacy:old-utils:jar:1.2.0:compile
[INFO] |  \- com.fasterxml.jackson.core:jackson-databind:jar:2.9.8:compile  (version conflict!)
[INFO] \- (resolved: 2.15.3 wins — "nearest definition" wins by default)

# Filter to find exactly where a specific library is coming from —
# essential when you spot a wrong version at runtime and need the source
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core

# Show only CONFLICTS — the ones Maven had to resolve, fastest way
# to spot the problem without reading the whole tree
mvn dependency:tree -Dverbose | grep -i "conflict\|omitted"

Excluding a problematic transitive dependency

// Scenario: a library pulls in an old, vulnerable, or conflicting version
// of a dependency you need a different version of
<dependency>
    <groupId>com.legacy</groupId>
    <artifactId>old-utils</artifactId>
    <version>1.2.0</version>
    <exclusions>
        <exclusion>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </exclusion>
    </exclusions>
</dependency>

// You're now responsible for declaring the version you actually want —
// without this, jackson-databind disappears entirely from the classpath
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.3</version>
</dependency>

Pinning a version globally — dependencyManagement

// Better than excluding everywhere: force a version project-wide,
// without needing exclusions on every offending dependency
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.15.3</version>  <!-- this version wins, everywhere, no exclusions needed -->
        </dependency>
    </dependencies>
</dependencyManagement>

// Spring Boot's spring-boot-dependencies BOM does exactly this at scale —
// it pins ~200 library versions so you never specify a version yourself
// for anything in the Spring ecosystem.

Other diagnostic commands worth knowing

# Check for available newer versions of your dependencies
mvn versions:display-dependency-updates

# Find unused or undeclared dependencies (catches "works by accident"
# dependencies that should be declared explicitly)
mvn dependency:analyze

# Where is this specific artifact actually coming from in the .m2 cache?
mvn dependency:resolve

# See effective POM — what your pom.xml actually resolves to after
# inheriting parent POMs and property substitution (essential when
# debugging "where did this config even come from")
mvn help:effective-pom

Repositories and Deployment

/*
 *  Resolution order Maven follows when looking for a dependency:
 *
 *  1. Local repository (~/.m2/repository) — your machine's cache
 *  2. Remote repositories — Maven Central by default, plus any
 *     company/private repos configured in pom.xml or settings.xml
 */

Publishing to a private repository (Nexus/Artifactory)

<distributionManagement>
    <repository>
        <id>company-releases</id>
        <url>https://nexus.company.com/repository/releases/</url>
    </repository>
    <snapshotRepository>
        <id>company-snapshots</id>
        <url>https://nexus.company.com/repository/snapshots/</url>
    </snapshotRepository>
</distributionManagement>
# Credentials go in ~/.m2/settings.xml — NEVER in pom.xml (it's committed to git)
# <servers><server><id>company-releases</id><username>...</username>
#   <password>...</password></server></servers>

mvn deploy -Pprod   # builds and uploads — this is what CI/CD runs on a tagged release
SNAPSHOT vs release versions

1.0.0-SNAPSHOT means "in development, can change" — Maven re-checks the remote repository for updates on every build (unless you use -o offline mode). A release version (1.0.0) is immutable once published — re-deploying the same version number to a release repository is normally rejected by the repository manager. Never ship SNAPSHOT dependencies to production; CI pipelines typically fail the build if any SNAPSHOT is present at release time.

Maven vs Gradle

Aspect Maven Gradle
Configuration XML (pom.xml), declarative, verbose Groovy/Kotlin DSL, programmable
Build speed Good Faster — incremental builds, build cache
Convention Strong, rigid lifecycle Flexible, more to configure yourself
Learning curve Predictable once learned Steeper — DSL flexibility means more ways to do things
Dominant in Enterprise Java, Spring Boot (still default) Android (mandatory), growing in new JVM projects

See Gradle Basics for the DSL approach in detail.

Common Pitfalls

Committing target/ to git
# Always in .gitignore — generated, large, and causes merge conflicts
target/
*.class
Hardcoding versions in every dependency
// ❌ Update Spring Boot? Now you're hunting 15 version numbers
<version>3.1.4</version>  <!-- repeated in every <dependency> -->

// ✅ Centralise via properties or a parent BOM
<properties><spring-boot.version>3.2.0</spring-boot.version></properties>
<version>${spring-boot.version}</version>
Ignoring -U when a SNAPSHOT seems stale
# A teammate pushed a fix to a shared SNAPSHOT module, but your build
# still uses the old cached one — Maven trusts local cache by default
mvn clean install -U   # forces a check against the remote repo

Interview Questions

🎓 Junior level

Q: What is the Maven build lifecycle?
A fixed, ordered sequence of phases: validate, compile, test, package, verify, install, deploy. Running any phase executes every phase before it automatically — mvn install compiles and tests first, it doesn't skip ahead.

Q: What is the difference between mvn install and mvn deploy?
install copies the built artifact into your local repository (~/.m2/repository), making it available to other projects on the same machine. deploy uploads it to a remote shared repository (Nexus, Artifactory) so other developers and CI pipelines can use it. Deploy is almost always a CI/CD-only step.

Q: What does the scope element on a dependency control?
Where the dependency is available: compile (default, everywhere), test (only test code, not bundled in the final artifact), provided (available at compile time, expected to be supplied by the runtime — like a servlet container), runtime (needed at runtime but not for compiling your code).

🔥 Senior level

Q: How does Maven resolve a version conflict between two transitive dependencies?
Maven uses "nearest definition wins" — if two dependencies in the tree pull in different versions of the same artifact, the one declared at the shallowest depth in your dependency tree wins, regardless of which version is newer. If both are at the same depth, the first one declared in the POM wins. This can silently select an older, even vulnerable, version. The fix is explicit: either an <exclusion> on the offending parent dependency plus a direct declaration of the version you want, or a global pin via <dependencyManagement> — the latter is preferred at scale since it requires no per-dependency exclusions.

Q: What is a BOM (Bill of Materials) and why does Spring Boot use one?
A BOM is a pom.xml with packaging=pom that contains only a <dependencyManagement> block pinning versions for a coordinated set of artifacts — no actual dependencies are pulled in by importing it. spring-boot-dependencies pins ~200 library versions known to work together. Importing it via <scope>import</scope> means you never specify a version for any Spring-ecosystem dependency — you just declare the artifact, and the BOM supplies a tested, compatible version automatically.

Q: Why might a build succeed locally but fail in CI?
Most commonly: a stale local .m2 cache hides a real dependency resolution problem that a clean CI environment exposes — fix by testing with mvn clean install -U locally. Other common causes: a provided-scope dependency genuinely missing at runtime in CI's deployment target, environment-specific profiles not activated the same way in CI as locally, or a SNAPSHOT dependency that updated between your last local build and the CI run. mvn dependency:tree and mvn help:effective-pom are the first two diagnostic commands to reach for.