Maven Interview Questions · 2026

Maven Interview Questions (2026): Build & Dependency Q&A, With Answers

Maven turned 21 in 2025, and it's still the build tool most Java shops actually run. JetBrains' State of Developer Ecosystem 2025 report puts it at 67 percent adoption among Java developers, ahead of Gradle and everything else combined (JetBrains, 2025). That's not nostalgia talking. Most large enterprise Java codebases, and a solid chunk of Spring Boot projects generated straight from start.spring.io, were laid down years ago on Maven, and nobody rewrites a working build config just to chase a newer tool. Numbers like that explain why Maven interview questions still show up in almost every backend and DevOps loop, even at companies that have moved half their newer services to Gradle. Prep guides love to spend their time on multi-module reactor tricks and custom plugin authoring, because those topics feel advanced and interesting to write about. My opinion, and I could be wrong about it: dependency scopes and lifecycle phase ordering cause more real breakage, and more real interview stumbles, than either of those. You can go years without writing a custom plugin. You can't avoid explaining why a servlet-api jar showed up inside your WAR when it should've stayed on the container's classpath.

This page covers Maven interview questions across eight areas: the build lifecycle and the difference between a phase and a goal, POM structure and inheritance, dependency scopes, transitive dependencies and conflict resolution, plugins, repositories, profiles, and multi-module reactor builds, plus a section on where Gradle actually differs and whether that difference matters for you. Examples use real pom.xml fragments and the CLI commands Maven shops actually run day to day, not pseudocode.

52Questions
Lifecycle & Dependency ScopesCore Topic
POM XML & CLIFormat
compileDefault Scope

What Maven actually does: the build lifecycle

Nearly every Maven interview starts here, and it's also where the shakiest answers show up: candidates can name phases but can't explain why the order matters.

Easy questions

15

Maven standardizes the entire build process, compiling, testing, packaging, and dependency resolution, into one convention-driven tool instead of a pile of hand-rolled shell scripts that every project reinvents slightly differently. It also standardizes how a project describes itself through the POM, so a new engineer can clone a Maven project and run one command instead of reverse-engineering someone else's build script.

modelVersion (always 4.0.0 for any current Maven), groupId, artifactId, and version. That's genuinely it, packaging defaults to jar if you leave it out entirely. Everything else, dependencies, plugin config, properties, profiles, sits on top of that four-element skeleton.

xml
<project xmlns="http://maven.apache.org/POM/4.0.0">
 <modelVersion>4.0.0</modelVersion>
 <groupId>com.example</groupId>
 <artifactId>payments-service</artifactId>
 <version>1.4.2</version>
</project>

compile is the default, available on every classpath and transitive to anything depending on your module. provided means the JDK or a container supplies it at runtime, available for compiling and testing but excluded from the packaged artifact. runtime is the mirror image, not needed to compile against but required once the app actually runs, a JDBC driver is the textbook case. test only exists for test compilation and execution, JUnit or Mockito, and it never leaks into your shipped artifact.

If your project depends on library A, and A depends on library B, Maven pulls B into your build automatically. You never declared B yourself. That's most of why a fresh Spring Boot project with a handful of direct dependencies in your own pom can drag in 80-plus jars total.

A dependency is code your application uses, a library your classes import and call. A plugin is code that acts on the build itself, compiling your sources, running your tests, packaging a jar, none of which your application code ever imports directly. Dependencies ship inside your artifact depending on scope; plugins never do, they're tooling, not application code.

The local repository is a cache on your own machine, ~/.m2/repository by default, holding every artifact Maven has ever downloaded or built so the next build doesn't re-fetch it. Maven Central is the public repository Maven checks by default when something isn't in your local cache. A private remote repository, Nexus, Artifactory, GitHub Packages, is something a company runs itself for internal artifacts, and it's often set up to also proxy and cache Central so a whole engineering org isn't hammering the public internet on every clean build.

A profile is a named block of extra or overridden configuration, properties, dependencies, plugin settings, that only applies when it's active. The common case: different values per environment, a dev database URL versus a prod one, or different behavior per context, running static analysis and coverage in CI while skipping both locally for speed, without maintaining two separate pom.xml files.

The Maven Wrapper is a small script pair, mvnw for Unix and mvnw.cmd for Windows, plus a.mvn/wrapper directory holding a properties file that pins an exact Maven distribution version and download URL. When someone runs./mvnw instead of mvn, the wrapper downloads that exact Maven version into a local cache if it's not already there, then invokes it. Nobody needs Maven installed globally at all, they just need a JDK.

Teams commit it because "works on my machine" for Maven itself is a real problem. Two developers with Maven 3.6 and 3.9 installed can get subtly different dependency resolution or plugin default behavior, and CI runners often ship whatever Maven version happened to be baked into the image. Pinning the wrapper's version in.mvn/wrapper/maven-wrapper.properties means everyone, including CI, builds with the identical Maven binary, and bumping the project's Maven version becomes a one-line change reviewed in a pull request instead of a tribal-knowledge Slack message.

mvn install runs the default lifecycle through the install phase, which copies your built artifact, jar, war, whatever packaging produced, plus its pom, into your local repository at ~/.m2/repository. That makes it available as a dependency for other Maven projects on the same machine. Nothing leaves your computer.

mvn deploy runs everything install does and then executes the deploy phase, which uploads the artifact to a remote repository defined in your pom's distributionManagement section, typically a company Nexus or Artifactory, or Maven Central for open source releases. That's the step that actually makes an artifact available to teammates or CI machines that don't have it built locally. In day to day work you almost never run deploy by hand, it's usually a CI job step gated behind a tag or a release branch.

~/.m2/repository is a flat, predictable directory tree keyed by groupId, turned into folders, artifactId, and version. For every artifact it holds the jar itself, the pom, checksums, and often a maven-metadata.xml file tracking the latest SNAPSHOT timestamp or the latest release version known for that coordinate. Anything you've ever downloaded as a dependency, plus anything you've ever mvn installed locally, lives here.

Deleting it is safe in the sense that Maven will just re-download everything it needs from whatever remote repositories your settings.xml points at, so you won't lose your source code. But it's not free. The first build afterward will be slow while it re-fetches your entire dependency graph, and if you had artifacts installed locally that were never actually published anywhere, for example an internal library a coworker handed you as a jar through install:install-file, deleting the repo genuinely loses that artifact until someone rebuilds or re-installs it.

GAV is groupId, artifactId, version, the three fields that together uniquely identify a single artifact in a repository. groupId is usually a reversed domain name identifying the organization or project, artifactId is the specific module name, version identifies exactly which build of that module you want.

The version matters more than people expect because Maven, unlike npm, doesn't do implicit semantic version range resolution by default. Ask for 2.3.1 and you get exactly 2.3.1's jar, byte for byte, not "something compatible with 2.3.1." Get the version wrong, even by a patch digit, and you can silently end up on an artifact with a fixed CVE reintroduced, a removed method your code was relying on, or a different transitive dependency set entirely, and none of that shows up as a build error unless something actually fails to compile or a test happens to catch it.

pom.xml describes your project, its dependencies, its build steps, its plugins, and it's meant to travel with the source code, checked into git, identical for every developer and every CI run. settings.xml describes the environment Maven runs in, which repositories to use, credentials for private repositories, proxy configuration, mirror rules, and which profiles are active by default. It lives outside the project, typically at ~/.m2/settings.xml for a single user, or in $MAVEN_HOME/conf/settings.xml for a machine-wide default.

Because settings.xml is exactly where private repository credentials and proxy passwords tend to live, checking it into a project repository is a real way to leak secrets. Teams that need shared settings usually distribute a settings-security.xml with encrypted passwords, or inject credentials through CI environment variables into a settings.xml generated at build time, never a plaintext file sitting in version control next to the source.

A BOM is a special pom.xml, packaged as type pom, that contains nothing but a dependencyManagement section pinning a coordinated set of versions for a family of related artifacts, for example spring-boot-dependencies or the AWS SDK's BOM. You pull it into your own project by adding it as a dependency with type pom and scope import inside your own dependencyManagement block, and every artifact from that family becomes version-pinned in your project without you writing a version number for each one.

xml
<dependencyManagement>
 <dependencies>
  <dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-dependencies</artifactId>
   <version>3.2.5</version>
   <type>pom</type>
   <scope>import</scope>
  </dependency>
 </dependencies>
</dependencyManagement>

The real value isn't saving keystrokes, it's that the library authors already tested that whole set of versions together. Hand-picking versions yourself means you're the one discovering, usually in production, that library A 2.1 and library B 4.0 don't actually work together even though each compiles fine on its own.

Marking a dependency optional in your library's pom means Maven still resolves it and puts it on your own build's classpath, so your code compiles and your tests run against it normally. What changes is what happens downstream: when someone else adds your library as a dependency, that optional dependency does not get pulled in transitively. If their code actually needs that functionality, they have to explicitly declare that dependency themselves.

Exclusion is the opposite direction and a different concern entirely. You use exclusions when you're the consumer of a library and you don't want one of its transitive dependencies showing up on your classpath at all, usually because you're bringing in a different version yourself or you know that piece of it is dead weight for how you're using the library. Optional is the library author deciding what's implicit, exclusion is the consumer deciding what to reject.

source and target independently control the Java language level the compiler accepts and the bytecode version it emits, but they don't touch which JDK class library APIs are actually available. If you compile with source 8, target 8, but you're actually running the build on a JDK 17 installation, the compiler happily lets you call a method that was added in Java 11, compiles clean, and then throws NoSuchMethodError the moment that code runs on an actual Java 8 runtime, because the compiler checked language syntax against Java 8 rules but linked against JDK 17's bootclasspath the whole time.

release, available from Java 9 onward, closes exactly that gap. Setting release to 8 tells the compiler to use language level 8, bytecode target 8, and the correct historical Java 8 API signatures as the actual compilation target, rejecting any API introduced after that version at compile time instead of letting it slip through and fail at runtime on an older JVM.

Medium questions

25

The default lifecycle has more than twenty phases, but the ones people actually reference day to day are validate, compile, test, package, verify, install, and deploy (Apache Maven docs). Running mvn package doesn't just run the package phase, it runs every phase before it too, in order, since phases in the same lifecycle are cumulative.

bash
mvn package
# runs, in order: validate -> initialize -> generate-sources -> process-sources
# -> generate-resources -> process-resources -> compile -> process-classes
# -> generate-test-sources -> process-test-sources -> test-compile -> test
# -> prepare-package -> package

A phase is a named stage in the lifecycle, compile, test, package. A goal is the actual unit of work a plugin performs, compiler:compile, surefire:test, jar:jar. A phase runs whichever goals are bound to it, zero, one, or several, and you can also invoke a goal directly without touching the lifecycle at all, mvn dependency:tree runs one goal and nothing else.

dependencies lists what your module pulls in and compiles or runs against right now. dependencyManagement doesn't add a single dependency to the build by itself, it's a menu of pinned versions and scopes that child modules can opt into by declaring just groupId and artifactId, no version number required (Apache Maven POM reference). It's how a fourteen-module project keeps every module on the same Jackson version without hunting through fourteen separate pom.xml files during an upgrade.

xml
<!-- parent pom.xml -->
<dependencyManagement>
 <dependencies>
  <dependency>
   <groupId>com.fasterxml.jackson.core</groupId>
   <artifactId>jackson-databind</artifactId>
   <version>2.17.1</version>
  </dependency>
 </dependencies>
</dependencyManagement>

<!-- child pom.xml, version inherited -->
<dependencies>
 <dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
 </dependency>
</dependencies>

groupId, version, properties, dependencyManagement, plugin management, and repository config all flow down by default. artifactId and profiles don't inherit, each module needs its own artifactId since two modules obviously can't share one. People get this wrong on a whiteboard more than you'd expect, usually assuming profiles cascade the same way dependencyManagement does. They don't.

It should've been provided, not compile. Tomcat already ships its own servlet-api implementation on its classpath, so packaging your own copy inside the WAR means two competing copies of the same classes sitting in different classloaders, which usually surfaces as a confusing NoSuchMethodError instead of a clean startup failure. provided keeps the jar available for your code to reference at compile time, but leaves it out of the final artifact, trusting the container to supply the real one.

Maven uses nearest-wins mediation: whichever version sits closest to your project at the top of the dependency tree beats a version buried deeper, regardless of which one is newer. If two candidates sit at the exact same depth, the one declared first in your pom.xml wins the tie. That's a genuinely different rule than "highest version wins," and it's the most common reason a library behaves differently than its own docs describe, someone's running an older transitive copy without realizing it.

Packaging type sets default bindings. Declare packaging as jar, or leave it out since jar is the default, and Maven quietly wires compiler:compile to the compile phase, surefire:test to test, and jar:jar to package, with zero plugin configuration written anywhere. Change packaging to war or pom and the default binding set changes with it. You only need an explicit plugin block with an executions section for a goal outside that default set, or to override the version of one that's already there.

Yes, directly: mvn plugin-prefix:goal, no phase involved. mvn dependency:tree, mvn versions:display-dependency-updates, and mvn archetype:generate all work this way, one specific goal on demand, ignoring the lifecycle entirely. Side note: versions:display-dependency-updates is the single most underused command I know of. Most teams find out a dependency is three major versions behind from a security scanner instead of just running one free command themselves.

A version ending in -SNAPSHOT is a work in progress; deploy it again with the same number and it overwrites the previous one, which is exactly what you want during active development. A release version, 1.4.2, no suffix, is meant to be fixed and reproducible: once published, the expectation across the ecosystem is that its contents never change, so anyone resolving 1.4.2 next year gets the same jar someone resolved today. Silently redeploying a release number with different code inside breaks that assumption for everyone downstream who already cached it.

A few ways, and they can combine. Explicitly with -P on the command line. activeByDefault, which makes a profile the fallback whenever nothing else gets explicitly activated. Or automatically, based on a condition Maven checks itself: a specific JDK version, an OS family, a system property, even whether a particular file exists on disk. That last one is how a lot of "detect if we're inside Docker" profile tricks get built.

xml
<profiles>
 <profile>
  <id>jdk17-only</id>
  <activation>
   <jdk>17</jdk>
  </activation>
  <properties>
   <maven.compiler.release>17</maven.compiler.release>
  </properties>
 </profile>
</profiles>

A parent pom.xml with packaging set to pom lists its child directories under modules, and running mvn install from that root builds all of them in one pass. The build order isn't the order they're listed in. Maven's reactor reads every module's own pom, figures out which modules depend on which others, and computes a build order from that dependency graph so nothing builds before something it actually needs.

xml
<!-- root pom.xml -->
<packaging>pom</packaging>
<modules>
 <module>common</module>
 <module>api</module>
 <module>worker</module>
</modules>

Maven is declarative XML against a fixed lifecycle, you describe what your project is and Maven runs a predetermined phase sequence against it. Gradle is a Groovy or Kotlin DSL built around a task graph you can shape more freely, plus incremental build support and a build cache that skip work Gradle can prove hasn't changed since the last run. In practice, Maven tends to be more predictable and easier for a new engineer to reason about from the outside, and Gradle tends to win on raw build speed once a codebase and its CI setup are large enough for incremental builds to actually matter.

Surefire is wired into the default lifecycle for jar-packaged projects out of the box, its test goal is bound to the test phase automatically, that's a built-in packaging default Maven ships with. Failsafe has no equivalent built-in binding. Just declaring the plugin in your build/plugins section doesn't attach its goals to any phase, so mvn verify will run right past integration-test and verify without ever invoking a single IT.

xml
<plugin>
 <groupId>org.apache.maven.plugins</groupId>
 <artifactId>maven-failsafe-plugin</artifactId>
 <executions>
  <execution>
   <goals>
    <goal>integration-test</goal>
    <goal>verify</goal>
   </goals>
  </execution>
 </executions>
</plugin>

You have to explicitly bind integration-test and verify in an executions block. Miss that, and the build reports success while every *IT.java class in your project silently never executed, a genuinely nasty failure mode because there's no error, no warning, just a green build with zero integration coverage.

Both can bundle your classes and every dependency's classes into one jar, but they solve different problems underneath. Assembly plugin works off a descriptor, jar-with-dependencies being the common one, and its job is basically archive assembly, unpack every dependency jar and repackage the contents into one artifact. It doesn't touch package names, and its handling of duplicate files across dependencies, most commonly META-INF/services entries or spring.factories files, is naive, typically last-one-wins or first-one-wins depending on processing order, silently dropping the other dependency's registrations.

Shade plugin does the same bundling but adds two things assembly doesn't have. It supports transformers, most importantly ServicesResourceTransformer, which actually merges duplicate META-INF/services files line by line instead of overwriting one with the other, which matters enormously for anything using ServiceLoader, JDBC drivers, SLF4J bindings, Jackson modules. It also supports relocation, rewriting package names of a bundled dependency, which is how you avoid classpath collisions when your own code and a dependency both transitively pull in different versions of the same library, shading one into a private package namespace so they can coexist.

The asterisk marks a node the tree doesn't expand further because that exact artifact and version already appeared earlier in the tree at a shallower or equal position. Maven is telling you "I've already resolved this coordinate, here's where," and it prunes the redundant branch purely to keep the output readable rather than printing the same subtree five times for five different modules that all happen to depend on, say, the same logging library.

What it doesn't tell you by default is whether a conflict actually happened, which version won, or why. For that you need the -Dverbose flag on dependency:tree, which prints the full unpruned tree along with annotations like omitted for conflict with, omitted for duplicate, or omitted for cycle next to entries Maven decided not to use. If you're actually chasing a version mismatch bug, plain dependency:tree just tells you the final shape, verbose tells you the decision history.

It reports two things by scanning compiled bytecode for actual class references: used undeclared dependencies, meaning your code references classes that come transitively from a dependency you never declared directly, fragile because if the direct dependency ever drops or changes that transitive one, your build breaks with no obvious cause, and unused declared dependencies, meaning you declared something in your pom that no compiled class in your project appears to reference at all.

The false positives come from the fact that it can only see direct class references in bytecode, it has no idea about runtime-only usage. A JDBC driver loaded via Class.forName or the driver manager's SPI, an SLF4J binding picked up through ServiceLoader at startup, an annotation processor consumed at compile time rather than referenced in your own code, a Jackson module registered by classpath scanning, all of these are dependencies your application genuinely needs but that dependency:analyze will flag as unused because nothing in your source literally imports a class from them.

Purge-local-repository scopes its deletion to the artifacts your current reactor actually depends on, walking your project's own dependency tree and removing only those coordinates from the local cache, rather than wiping every artifact from every unrelated project you've ever built on that machine. By default it also re-triggers resolution afterward, so you get a clean re-download of just your project's dependencies as a way to genuinely verify the build is reproducible from a clean cache, without paying the cost of re-downloading gigabytes of unrelated dependencies for every other Maven project on the same laptop.

It also has narrower options that matter in practice, -Dinclude scoped to a single groupId:artifactId when you suspect one specific jar got corrupted by a network blip mid-download, and -DreResolve=false if you just want the deletion without immediately forcing a re-download. A full rm -rf of ~/.m2/repository is the blunt instrument for "I don't trust anything in here anymore," purge is the scalpel for "I don't trust this one dependency chain."

Maven's mediation is nearest-wins by tree depth, not highest-version-wins. It picks whichever version sits at the shallowest depth in the dependency graph relative to your own project's pom, and if two candidates sit at exactly the same depth, whichever one was declared first in the pom wins that tie. There's no comparison of version numbers involved in the default algorithm at all.

That surprises people because a deep transitive dependency pinning a genuinely newer, safer version can lose outright to a shallow dependency pinning an older, sometimes vulnerable version, purely because of tree position, with no warning printed anywhere in a normal build. If you actually want the newer version to win regardless of depth, you have to force it explicitly through dependencyManagement in your own pom, mediation alone won't get you there.

mvn test runs the default lifecycle up to and including the test phase: validate, initialize, generate-sources, compile, process-classes, generate-test-sources, test-compile, and test. That's compile your code, compile your tests, run your unit tests through surefire. Nothing gets packaged.

mvn verify runs everything test does, plus package, pre-integration-test, integration-test, post-integration-test, and verify itself. This is where a packaged jar or war actually gets built, and where failsafe's integration tests actually execute if you've bound them, along with any quality gate plugins wired to the verify phase, coverage thresholds, static analysis checks, license checks. mvn install runs everything verify runs, plus the install phase on top, which copies the already-verified artifact into your local repository. So install is strictly a superset of verify, which is strictly a superset of test, each one does more work, not different work.

dependencyConvergence checks that for every artifact coordinate appearing anywhere in your resolved dependency tree, every path that leads to it resolves to the exact same version. It's stricter than mediation: mediation is happy to pick a winner and move on even if three different modules were asking for three different versions of the same library, dependencyConvergence fails the build the instant it detects that disagreement exists at all, regardless of whether mediation would have quietly resolved it to something reasonable.

xml
<rules>
 <dependencyConvergence/>
</rules>

Teams turn it on in larger multi-module builds specifically because silent mediation is a real production risk. A coworker bumping one module's dependency version and creating an accidental disagreement three modules away is exactly the kind of thing that passes a normal build and then breaks behavior at runtime months later. Turning it into a hard build failure means the disagreement gets fixed at code review time instead of debugged in an incident.

The most common root cause isn't filtering configuration at all, it's that the property was defined inside a Maven profile that's active on your machine, through a settings.xml activation or a local -P flag you always pass out of habit, but that profile isn't active during the actual build that produces the production artifact, so the token is left as a literal unresolved ${db.password} string in the packaged jar.

But even when filtering is configured and working correctly, this pattern has a deeper problem: resource filtering substitutes values at build time, baked permanently into that one jar. If the same artifact is meant to be promoted unchanged across dev, staging, and production, which is the entire point of building it once, filtering a per-environment secret into it at build time is actually the wrong tool, because you either need a different jar per environment, defeating "build once," or the value has to be genuinely externalized, an environment variable, a mounted config file, a secrets manager read at startup, rather than compiled into the artifact at all.

activeByDefault only takes effect when nothing else has explicitly activated any profile for that particular invocation, through -P on the command line, an activation property, an OS or JDK trigger, or a file existence check. The moment any other profile becomes active through any of those mechanisms, activeByDefault is switched off entirely for that build, not just deprioritized, off.

This produces a specific, recurring confusion in real projects: a team relies on an activeByDefault profile for months, everyone's local builds and CI both get its settings automatically, then someone adds a genuinely necessary -P ci flag for a new pipeline step, and suddenly builds behave differently because the default profile's properties, plugin configs, or dependencies just vanished from that build entirely, with no error, because Maven treats "you explicitly asked for a profile" as meaning you don't want the implicit default anymore.

In a multi-module reactor, hand-editing the version means touching every child pom's version and every parent reference to that version across the whole tree for a single release, error prone and a merge-conflict magnet when multiple people are cutting branches around the same time. The revision pattern lets every module declare its version as literally the placeholder ${revision}, and a single -Drevision=1.4.2 passed on the command line at build or release time resolves all of them consistently in one shot.

flatten-maven-plugin exists because that trick only fully works for your own reactor build. The pom you actually deploy to a remote repository still needs a real, resolved version string in it, because a consumer resolving your artifact from Nexus later isn't running your reactor with that revision property set, they're just reading the deployed pom's literal contents. Flatten rewrites a simplified pom with the property substituted and inheritance flattened out before deploy, so what actually lands in the repository is a normal, self-contained pom rather than one full of unresolved placeholders.

A parent POM is about inheritance, children reference it through a parent element and pull down its dependencyManagement, pluginManagement, properties, and plugin configuration. An aggregator POM is about reactor scope, it has a modules list telling Maven which projects to build together and lets Maven compute build order across them, but a module listed there doesn't automatically inherit anything from the aggregator unless that module's own pom separately declares it as its parent.

In practice a single pom.xml is almost always both simultaneously, and that's the standard, expected pattern, not a coincidence. A top-level project pom typically lists its modules so it functions as the aggregator that drives the reactor build order, while each child module also names it as its parent, simultaneously providing shared configuration through inheritance. They're separate mechanisms that happen to usually live on the same file, and you can technically split them, an aggregator that isn't anyone's parent, or a parent that aggregates nothing, but almost nobody does that in real projects.

mirrorOf tells Maven that any repository request matching this id pattern should actually be redirected to this mirror's URL instead. Companies commonly point mirrorOf at a bare star so every dependency request, whether it's aimed at Central or anywhere else, gets routed through their own Nexus or Artifactory proxy for caching and auditing.

xml
<mirror>
 <id>company-mirror</id>
 <mirrorOf>external:*</mirrorOf>
 <url>https://nexus.internal.example.com/repository/maven-public/</url>
</mirror>

The problem with a bare star is that it captures literally everything, including a private, internal repository you've explicitly declared in your pom with its own distinct id and URL for a reason, like an internal release repo that isn't proxied through that mirror at all. Requests meant for that repository get silently rerouted to the mirror instead, which can't actually serve those artifacts, and you get resolution failures that look like a missing dependency when the real cause is mirror configuration swallowing a repository it shouldn't. The fix is scoping mirrorOf to external:*, which matches everything except repositories pointing at your own local network, or listing specific repository ids to mirror instead of a bare wildcard.

Hard questions

12

Lifecycle phases are cumulative, so getting to test means Maven walks through validate, compile, and everything between first, running whatever goals are bound along the way. test just sits after compile in the sequence. That's also why mvn install always reruns your whole test suite unless you skip it explicitly, and -DskipTests and -Dmaven.test.skip=true are not the same flag: skipTests still compiles the tests, maven.test.skip skips compiling them entirely.

system scope is for a jar with no Maven coordinates at all, something a vendor handed you as a raw file, and you point at it with an explicit systemPath instead of letting Maven resolve it normally. The catch: that path is usually a local filesystem path, so the build stops being reproducible on any machine that doesn't have the exact same file sitting in the exact same place. The better fix, almost always, is installing that jar into a private or even folder-based repository once with real coordinates, so it resolves like anything else.

Two real options. Add an exclusions block on whichever dependency is dragging in the unwanted transitive version, then declare the version you actually want directly. Or, cleaner in a multi-module project, pin it once in dependencyManagement at the parent so every module resolves the same version without each one needing its own exclusion.

bash
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core
xml
<dependency>
 <groupId>com.example</groupId>
 <artifactId>legacy-client</artifactId>
 <version>3.2.0</version>
 <exclusions>
  <exclusion>
   <groupId>com.fasterxml.jackson.core</groupId>
   <artifactId>jackson-databind</artifactId>
  </exclusion>
 </exclusions>
</dependency>

Whatever settings.xml CI is using, not the one on your laptop. Most of these failures trace back to CI running a different, often minimal, mirror or repository configuration, missing credentials for a private repo your local Maven already has cached, or CI running in offline mode against a cache that never downloaded the artifact in the first place. I'd check CI's actual settings.xml and.m2 state before touching the pom at all, since the pom is usually innocent here.

No, and this trips up a genuinely large number of people the first time they touch a multi-module project. The reactor ignores listing order entirely and computes build order from the real inter-module dependency graph, so reordering modules changes nothing about build sequence, only cosmetic reading order in the file. If module B needs module A and A keeps building after B, the actual bug is almost always a missing or wrong dependency declaration from B to A, not the module list.

I don't think most teams should bother. Migrating a working multi-module Maven build to Gradle is a real project with real risk, and the speed gain only shows up once you've also invested in configuring Gradle's build cache and incremental compilation correctly, a step teams often skip and then wonder why the migration didn't feel faster. My honest take: most "Maven vs Gradle" arguments online are really "which one does my team already know" arguments wearing a technical costume. If you're joining an existing Maven codebase, get good at Maven first instead of lobbying to rip it out in your first quarter. I don't have a clean number on how much faster a typical enterprise migration actually gets once the dust settles, it depends heavily on module count and how good the CI caching already was before anyone touched Gradle.

Maven's parallel reactor scheduler only understands one thing when deciding what can run concurrently, the declared inter-module dependency graph in your poms. If module B doesn't declare a dependency on module A, Maven considers them independent and will happily run the same lifecycle phase for both at the exact same wall-clock moment on different threads. It has no visibility into side effects a plugin performs outside that declared graph, writing to a shared file, a shared generated-sources directory, a shared target folder one level up, or a code generator that isn't written to be safely invoked from multiple threads at once.

That's almost always the actual root cause here, not a real cycle, but a plugin, commonly an older antrun script, a code generator, or a resource-copying step, that two modules both happen to invoke and that isn't thread-safe, so their concurrent executions stomp on each other's output. Maven does try to warn you about this, plugins can be annotated thread-safe, and when you pass -T, Maven prints a warning for any plugin execution it can't confirm is safe to run in parallel, but it doesn't block the build, it just warns, so teams often run for a long time with -T before the race actually manifests as a visible failure. The fix is almost never reordering modules, it's finding the specific plugin execution doing shared, non-idempotent filesystem work and either making its output path module-specific or dropping parallelism for that one execution.

dependency:tree only reflects Maven's own build-time resolution decision, which controls what goes on the compile and test classpath during your own build. It has no visibility into what actually happens after your artifact leaves your build and gets deployed, and that's where the mismatch between what Maven resolved and what's actually loaded comes from.

The usual real culprits are outside Maven's resolution entirely. A fat jar built by shade or assembly can end up with duplicate class files from two different versions bundled together, and without a proper merge transformer the packaging tool just picks whichever one it processed last or first, independent of what dependency:tree told you was the resolved winner for the build. In an application server or container, parent-first classloading can mean a logging jar sitting in the container's own shared lib directory takes precedence over whatever you shipped inside your WAR or fat jar, no matter what your pom says. And a completely different artifact under a different groupId that happens to provide the exact same classes, a relocated fork, or a vendor's repackaged copy, won't even show up in your dependency tree because Maven has no idea it's the same code. When the tree and the runtime disagree, the actual answer is almost always found by unzipping the deployed artifact and grepping for the class, not by re-running dependency:tree again.

A SNAPSHOT version string like 1.4.0-SNAPSHOT is not a fixed artifact, it's an alias for whatever the most recent deployed build under that coordinate happens to be at the moment it's resolved. Maven caches resolved SNAPSHOTs locally and, by default, only checks upstream for a newer one once per day, so your laptop can be quietly running against a build from three days ago while CI, often starting from a clean or short-lived cache, or running with -U, pulls whatever a teammate deployed an hour ago with a behavior change nobody's told you about yet.

To actually make this reproducible you have two real options. Either pin to the fully timestamped snapshot build, something like 1.4.0-20260718.153000-42, instead of the floating alias, which does exist and is resolvable through the repository's metadata, and gives you a genuinely fixed artifact just like a release. Or, better for anything crossing a team or CI boundary at all, stop depending on SNAPSHOTs across that boundary entirely and cut small, frequent, real pre-release versions instead. SNAPSHOTs are meant for iterating within a single developer's own inner loop, not as a stable coordinate other people's builds depend on.

provided means exactly what it says, this dependency is on the compile and test classpath so your code builds and your tests run fine, but Maven trusts that something external to your own artifact will supply it at actual runtime, the classic case being servlet-api when you're deploying a WAR into a container like Tomcat that already has its own servlet implementation on the server classpath.

A self-contained Spring Boot executable jar has no external container supplying anything, there's no Tomcat instance sitting there with extra jars on its own classpath, the fat jar is the entire runtime. spring-boot-maven-plugin's repackage goal deliberately excludes provided and system scoped dependencies from the nested libs it bundles into that jar, following the WAR convention, because it assumes you meant "something else provides this." If a dependency was marked provided out of habit, or copied from a WAR-based project's pom, rather than because something genuinely external supplies it, it compiles clean, tests pass, and only fails the moment the actual fat jar tries to load that class at startup in production, because the class genuinely isn't in the jar at all. The fix is simply using the default compile scope for anything that must actually ship inside a self-contained jar, reserving provided only for things a real external environment truly provides.

deploy runs after install in the lifecycle, and with a plain deploy setup it just uploads your artifacts straight to whatever URL is in distributionManagement. With a staging workflow, typically driven by nexus-staging-maven-plugin rather than the vanilla deploy plugin, deploy instead opens a new staging repository on the server and uploads artifacts there, not to the public release repository directly, so nothing is publicly visible yet.

Before upload, if maven-gpg-plugin is bound, usually to the verify phase, each artifact gets signed client-side, producing a detached.asc signature file alongside the jar and pom that gets uploaded too. That signature proves provenance, who actually built and published this, and is a completely separate concern from checksums, which the client computes and uploads alongside every artifact and which Nexus independently recomputes on receipt to confirm the bytes weren't corrupted or altered in transit, rejecting the upload outright on a mismatch. That's about transport integrity, not authorship.

Once everything's uploaded into the open staging repository, it typically has to be explicitly closed, at which point the server runs its own validation rules, checking the pom has required metadata, that javadoc and sources jars are present, that signatures actually verify, and only after that passes does someone explicitly release or promote the staging repository, the actual step that makes it visible in the public repository. That two-step close-then-release process exists specifically so a bad or incomplete deploy can be dropped before it ever becomes a public, effectively unrecallable artifact.

The first place to look isn't Maven's resolution at all, since it's telling you correctly what it decided, it's what actually happened to the artifact after packaging. If this is a shaded or assembled fat jar, both versions may well have been pulled in as separate transitive jars during the build, and the archiving tool, not Maven's dependency resolver, decides what happens when two jars both contain the same class file, typically first-seen-wins or last-seen-wins depending on processing order, completely independent of which version Maven's resolver considered the winner for the compile classpath. dependency:tree tells you what Maven picked for resolution, not what physically ended up inside the packaged artifact.

The second place is anything outside your own artifact entirely. An application server or container with its own shared lib directory holding an old copy of the same library from a previous deployment, loaded ahead of your app through parent-first classloading, will win regardless of what your pom says. So can a completely different, unmanaged artifact providing the same package under a different coordinate that Maven's tree wouldn't even flag as related. The reliable way to actually answer this is to stop trusting dependency:tree for this specific question and instead unzip the deployed jar or WAR, grep for the actual class file, and see what bytes are physically present, because that's the only source of truth about what the JVM is really loading.

How to prepare for a Maven interview in 2026

Skip another slide about lifecycle phases and just break something. Spin up a two-module project, a core library and an api module that depends on it, point one module's compile-scope dependency at a servlet API jar, and watch it show up in your packaged WAR when it shouldn't. Add a second library that pulls in a conflicting version of a JSON library you already depend on directly, run mvn dependency:tree, and actually read the tree instead of trusting the build went green. Reading about nearest-wins mediation is nothing like watching your own build silently pick an ancient Jackson version because it happened to sit one level closer to root than the copy you actually wanted.

Across mock interviews run through LastRoundAI tagged backend or Java, dependency-scope questions, provided versus runtime mostly, trip up more candidates than lifecycle phase-order questions, even though phase order gets drilled harder in most prep guides. My guess is that phase names are easy to flashcard, and scope behavior only really clicks once you've shipped a WAR that broke in production because of it. We don't have a clean percentage to put on that pattern, only that it comes up often enough in review to flag here.

Prove you understand this, not just memorized it

Reciting the nearest-wins rule out loud is not the same as defending it once an interviewer changes one detail on you: swaps which library got declared first, adds a third conflicting version, or asks what Gradle would've done instead. LastRoundAI's mock interview mode runs backend and Java rounds with follow-up questions that adapt to what you actually said instead of a fixed script, and the free plan includes 15 credits a month that reset monthly rather than piling up. Starter is $19/mo if fifteen sessions a month isn't enough runway.

If the harder part of the job hunt right now is finding enough backend or Java roles that actually list Maven or Spring in the requirements, rather than passing the interview once you land one, Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

How this list was built

Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.

What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.

If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

Frequently asked questions

How long does it take to prepare for a Maven interview?

If you already work with Maven day to day, a focused week on the areas you avoid in practice is usually enough. Coming in cold, expect three to four weeks. The gap is rarely knowledge; it is being able to explain something you normally just use.

What Maven topics come up most often?

Interviewers concentrate on the parts that cause production incidents rather than the parts that are pleasant to learn. Expect the fundamentals to be assumed and the follow-up questions to sit one layer below what a tutorial covers.

Do I need hands-on Maven experience to pass?

It shows quickly either way. Textbook answers hold up until the interviewer asks what you did when it broke, and that is usually the question that separates candidates. A small real project you can discuss honestly beats a longer list of familiarity claims.

Is Maven still worth learning in 2026?

For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.

Leave a Reply

Your email address will not be published. Required fields are marked *