Maven Extensions

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Maven 3.10.0 on JDK 26. Every example was run on 2026-10-16. Maven 3.9.16 and Maven 4.0.0-rc-7 (a release candidate) were run wherever the page says so. All runs used MAVEN_ARGS="-B -ntp" (batch mode, no transfer progress) and a private local repository, and from the console output the Scanning for projects line, the banner and separator lines, blank [INFO] lines, the Total time and Finished at lines and the Loaded N auto-discovered prefixes line of Maven 3.10 were left out. The directory of the experiments is shown as . in paths. The extension that the page builds, tidepool-build-notes, is a few lines of Java written for this page; the other extensions are real ones from Maven Central.

A Maven plugin adds goals: it does something when a lifecycle phase or the command line asks for it. A Maven extension changes Maven itself. It is code that is loaded into the Maven process, can replace or add components of the Maven container, and can hook into the whole session — before the POMs are read, between projects, after the last mojo. Most of the time a build does not need one; the build, the plugins and the plugin configuration do the work. Extensions exist for the things plugins cannot do: teach Maven a new transport protocol, read the POM from another file format, schedule modules differently, cache build results, check the environment before anything starts, or record what the build did.

The extension mechanism is also the reason the section on Writing Plugins is a separate page: a plugin is loaded late, in an isolated class loader, and an extension is loaded early, and sometimes into the same class loader as Maven.

Core extensions and build extensions

The official guide distinguishes two kinds by when they are loaded:

Core extension Build extension

Registered in

.mvn/extensions.xml (project), ~/.m2/extensions.xml (user, Maven 3.10), $MAVEN_HOME/conf/extensions.xml (installation, Maven 3.10), a JAR in ${maven.home}/lib/ext, or -Dmaven.ext.class.path=extension.jar

build/extensions/extension in a POM, or a plugin with <extensions>true</extensions>

Loaded

At startup, before the POMs are read

While the project is being built, after its POM is read

Class loader

The Maven core class loader (a realm of its own for each extension in .mvn/extensions.xml)

A realm of its own for each extension, isolated from the others

Can

Change how Maven reads POMs, schedules and builds, replace core components, hook the whole session

Add packagings, lifecycle mappings, Wagon providers and components that the plugins of that project use

Applies to

The whole session (every project)

The project that declares it (and, by inheritance, its children)

The guide explains that some extensions change Maven so deeply that they must be loaded early, and that an extension’s documentation should say which kind it is. The sections below build one extension and load it each way, so that the difference is visible.

The class loaders behind the difference

Maven uses Plexus Classworlds to build a graph of class loaders (realms). The guide to Maven classloading describes them; the diagram shows the parts that matter for extensions.

flowchart BT platform["Platform: the JRE classes"] system["System: Plexus Classworlds only"] core["Core: lib, lib/ext and core extensions"] api["API: filtered view of core, only exported packages"] bext["Build extension realms: one per extension, isolated"] project["Project realm: one per project"] plugin["Plugin realms: one per plugin"] system -->|imports| platform api -->|filters| core bext -->|imports| api project -->|imports| api project -->|exposes classes of| bext plugin -->|imports| project
  • The core realm holds the libraries of ${maven.home}/lib. Core extensions are added to it, so Maven itself and, through the API realm, every plugin can see them (only what they export; see Exported packages and artifacts).

  • The API realm is a filtered view of the core: it exposes only the exported packages of Maven and of the core extensions. It exists since Maven 3.3.1, the release that also introduced .mvn/extensions.xml.

  • A build extension gets its own realm that imports the API realm. One build extension cannot see another. Every Sisu (JSR-330) or Plexus component in its JAR is registered in the global container when the realm is created, and component references in a plugin descriptor are wired from that container.

  • Each project has a project realm that imports the API realm and exposes the classes of the build extensions bound to that project. Each plugin that is not a build extension has a plugin realm that imports the project realm. A plugin marked <extensions>true</extensions> uses the build extension realm instead of a plugin realm.

  • The guide warns that a build extension is loaded late and therefore has limited effect: anything that Maven has already done by the time the POM is read cannot be changed by it.

Writing an extension: lifecycle participants and event spies

The Maven guide for lifecycle extensions lists the three classes to extend, depending on what the extension must see:

  • org.apache.maven.AbstractMavenLifecycleParticipant: callbacks afterSessionStart, afterProjectsRead and afterSessionEnd. This is the place to read or modify the projects after the POMs are read (afterProjectsRead), for example to change a plugin configuration in all projects.

  • org.apache.maven.execution.AbstractExecutionListener: callbacks for the project and mojo events of the execution.

  • org.apache.maven.eventspy.AbstractEventSpy: a spy that receives every event object that Maven dispatches (execution events and also, for example, the dependency resolution request and result of each project).

Everything is a Sisu component. The extension class is marked with the JSR-330 annotations @Named and @Singleton, and the JAR carries a Sisu index, META-INF/sisu/javax.inject.Named, which lists the components so that the container can find them without scanning. The extension of this page has two components:

tidepool-build-notes/src/main/java/io/tidepool/ext/BuildNotesParticipant.java
package io.tidepool.ext;

import javax.inject.Named;
import javax.inject.Singleton;
import org.apache.maven.AbstractMavenLifecycleParticipant;
import org.apache.maven.MavenExecutionException;
import org.apache.maven.execution.MavenSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Core/build extension: runs around the whole session, before any plugin. */
@Named("tidepool-build-notes")
@Singleton
public class BuildNotesParticipant extends AbstractMavenLifecycleParticipant {
    private static final Logger LOG = LoggerFactory.getLogger(BuildNotesParticipant.class);

    @Override
    public void afterSessionStart(MavenSession session) throws MavenExecutionException {
        LOG.info("[tidepool-build-notes] session started, goals {}", session.getGoals());
    }

    @Override
    public void afterProjectsRead(MavenSession session) throws MavenExecutionException {
        LOG.info("[tidepool-build-notes] {} project(s) read, class loader {}",
                session.getProjects().size(), getClass().getClassLoader());
        session.getProjects().forEach(p ->
                LOG.info("[tidepool-build-notes]   {}", p.getId()));
    }

    @Override
    public void afterSessionEnd(MavenSession session) throws MavenExecutionException {
        LOG.info("[tidepool-build-notes] session ended");
    }
}
tidepool-build-notes/src/main/java/io/tidepool/ext/MojoCounterSpy.java
package io.tidepool.ext;

import java.util.concurrent.atomic.AtomicInteger;
import javax.inject.Named;
import javax.inject.Singleton;
import org.apache.maven.eventspy.AbstractEventSpy;
import org.apache.maven.execution.ExecutionEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** An EventSpy sees every event of the build, including those before the POMs are read. */
@Named
@Singleton
public class MojoCounterSpy extends AbstractEventSpy {
    private static final Logger LOG = LoggerFactory.getLogger(MojoCounterSpy.class);
    private final AtomicInteger mojos = new AtomicInteger();

    @Override
    public void onEvent(Object event) {
        if (event instanceof ExecutionEvent e) {
            if (e.getType() == ExecutionEvent.Type.MojoStarted) {
                mojos.incrementAndGet();
            } else if (e.getType() == ExecutionEvent.Type.SessionEnded) {
                LOG.info("[tidepool-build-notes] {} mojo execution(s) started", mojos.get());
            }
        }
    }
}

The POM compiles against Maven’s own API with provided scope: Maven supplies maven-core, JSR-330 and SLF4J at runtime from its core realm, and an extension must not bundle them. The release is 17 because the extension is meant to run on every Maven in this page, and the sisu-maven-plugin generates the index:

tidepool-build-notes/pom.xml (the relevant parts)
<dependencies>
  <dependency>
    <groupId>org.apache.maven</groupId>
    <artifactId>maven-core</artifactId>
    <version>3.9.16</version>
    <scope>provided</scope>
  </dependency>
  <dependency>
    <groupId>javax.inject</groupId>
    <artifactId>javax.inject</artifactId>
    <version>1</version>
    <scope>provided</scope>
  </dependency>
  <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.36</version>
    <scope>provided</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.eclipse.sisu</groupId>
      <artifactId>sisu-maven-plugin</artifactId>
      <version>1.1.0</version>
      <executions>
        <execution>
          <id>main-index</id>
          <phase>process-classes</phase>
          <goals><goal>main-index</goal></goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

The version matters. The official guide still shows 0.3.5 of the plugin. Built with it on JDK 26 (release 17) the plugin ran without a message and wrote no index, and the JAR had no META-INF/sisu entry at all; with 0.9.0.M4 and with 1.1.0 the same build produced the file. An extension without an index loads, but Maven never finds its components, so it silently does nothing. Check the JAR after building it:

$ mvn install   (Maven 3.10.0, extension project)
[INFO] --- sisu:1.1.0:main-index (main-index) @ tidepool-build-notes ---
[INFO] BUILD SUCCESS
$ unzip -l target/tidepool-build-notes-1.0.0.jar | grep -E 'class|sisu/javax'
       69  10-16-2026 00:00   META-INF/sisu/javax.inject.Named
     2817  10-16-2026 00:00   io/tidepool/ext/BuildNotesParticipant.class
     1628  10-16-2026 00:00   io/tidepool/ext/MojoCounterSpy.class
$ unzip -p target/tidepool-build-notes-1.0.0.jar META-INF/sisu/javax.inject.Named
io.tidepool.ext.BuildNotesParticipant
io.tidepool.ext.MojoCounterSpy

The JAR was then installed into the private local repository as io.tidepool.ext:tidepool-build-notes:1.0.0. The tidepool build used for the experiments is a parent POM with two modules, tidepool-model and tidepool-engine, each with one class. The extension, its POM and that build are this page’s own lab setup, not examples from the Maven documentation.

Core extensions

.mvn/extensions.xml

The recommended way since Maven 3.3.1 is the file .mvn/extensions.xml in the root of the project. It is part of the project and is committed with it, so everybody who builds the project gets the same extension without changing their installation. Before it existed, an extension had to be a (shaded) JAR copied by hand into the lib/ext directory of the Maven installation, or named on the command line. The file lists the extension by Maven coordinates, and Maven resolves it with its transitive dependencies from a repository like any other artifact (see Configuring Maven for the other files of the .mvn directory):

lab/.mvn/extensions.xml
<?xml version="1.0" encoding="UTF-8"?>
<extensions xmlns="http://maven.apache.org/EXTENSIONS/1.1.0">
  <extension>
    <groupId>io.tidepool.ext</groupId>
    <artifactId>tidepool-build-notes</artifactId>
    <version>1.0.0</version>
  </extension>
</extensions>

The namespace is the one of the guide, http://maven.apache.org/EXTENSIONS/1.1.0 (with the schema core-extensions-1.1.0.xsd). Running any goal now prints the lines of the extension before the first project is built:

$ mvn compile   (Maven 3.10.0)
[INFO] [tidepool-build-notes] session started, goals [compile]
[INFO] [tidepool-build-notes] 3 project(s) read, class loader ClassRealm[coreExtension>io.tidepool.ext:tidepool-build-notes:1.0.0, parent: ClassRealm[plexus.core, parent: null]]
[INFO] [tidepool-build-notes]   io.tidepool:tidepool:pom:1.0.0
[INFO] [tidepool-build-notes]   io.tidepool:tidepool-model:jar:1.0.0
[INFO] [tidepool-build-notes]   io.tidepool:tidepool-engine:jar:1.0.0
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-model ---
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-model ---
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-engine ---
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-engine ---
[INFO] [tidepool-build-notes] 4 mojo execution(s) started
[INFO] BUILD SUCCESS
[INFO] [tidepool-build-notes] session ended

The order shows what the extension can do: session started came before Maven had read any POM, the three projects were visible to afterProjectsRead before the first plugin ran, the spy counted the four mojo executions, and session ended was printed after BUILD SUCCESS. The class loader in the message is the extension’s own realm, coreExtension>io.tidepool.ext:tidepool-build-notes:1.0.0, whose parent is plexus.core, the realm of the Maven core. The same project and the same extension produced the same lines on Maven 3.9.16 and on Maven 4.0.0-rc-7. The extension was written against the Maven 3 API with javax.inject, and it worked unchanged on 4.0.0-rc-7; an extension that is written only for Maven 4 uses the Maven 4 API and jakarta.inject.

The command line, lib/ext and the extension classpath

Two older ways are still there. -Dmaven.ext.class.path= takes the path of a JAR (a path list), and puts it in an extension realm named maven.ext; there is no dependency resolution, so the JAR must be complete:

$ mvn -Dmaven.ext.class.path=ext/target/tidepool-build-notes-1.0.0.jar compile   (Maven 3.10.0, project without .mvn/extensions.xml)
[INFO] [tidepool-build-notes] session started, goals [compile]
[INFO] [tidepool-build-notes] 3 project(s) read, class loader ClassRealm[maven.ext, parent: ClassRealm[plexus.core, parent: null]]
...
[INFO] [tidepool-build-notes] 4 mojo execution(s) started
[INFO] BUILD SUCCESS
[INFO] [tidepool-build-notes] session ended

A JAR copied to ${maven.home}/lib/ext goes straight into the core realm (ClassRealm[plexus.core, parent: null] in the output, with no realm of its own). The lab used a copy of the Maven 3.10.0 distribution for this, because the installation must not be modified: the directory lib/ext of the distribution contains a README.txt, and the two directories hazelcast and redisson, and the copied JAR was found:

$ cp tidepool-build-notes-1.0.0.jar mvn-copy/apache-maven-3.10.0/lib/ext/
$ mvn compile   (the copy of Maven 3.10.0)
[INFO] [tidepool-build-notes] session started, goals [compile]
[INFO] [tidepool-build-notes] 3 project(s) read, class loader ClassRealm[plexus.core, parent: null]
...

The two methods change Maven’s installation or the command line and are the reason .mvn/extensions.xml was added: the guide calls the first one an inconvenient on-boarding step, and says the second forces everybody to repeat the option on every call. The environment variable MAVEN_OPTS or the file .mvn/maven.config can hold the -D option, but the extension’s coordinates in the project file are what the next developer, and the CI server, will find.

User-wide and installation-wide extensions (Maven 3.10)

The release notes of Maven 3.10.0 list support for user-wide extensions (in ~/.m2/extensions.xml) and installation-wide extensions (in $MAVEN_HOME/conf/extensions.xml), in addition to the project-specific .mvn/extensions.xml. They use the same format. The lab pointed Maven at a fake home directory with MAVEN_OPTS=-Duser.home=..., so that the real ~/.m2 was not touched, and put the file of the previous section there. Maven 3.9.16 ignored it, Maven 3.10.0 loaded it (Maven 4.0.0-rc-7 loaded it too, and also loaded the installation-wide conf/extensions.xml of a copy of its distribution):

$ MAVEN_OPTS=-Duser.home=./home mvn compile   (project without .mvn/extensions.xml)
3.10.0: [INFO] [tidepool-build-notes] session started, goals [compile]
        [INFO] [tidepool-build-notes] 3 project(s) read, class loader ClassRealm[coreExtension>io.tidepool.ext:tidepool-build-notes:1.0.0, ...
3.9.16: [INFO] BUILD SUCCESS     (no extension line at all)

With the same extension in more than one place Maven loads it once, from the place of highest precedence, and says so. -X lists the configured extensions in precedence order and the ones that were selected. In the lab the project file named version 1.0.0, the user file 0.9.0 and the installation file 0.8.0:

[WARNING] Found 2 extension conflict(s):
[WARNING] * Conflicting extension io.tidepool.ext:tidepool-build-notes: 0.9.0 vs 1.0.0 in ./home2/.m2/extensions.xml
[WARNING] * Conflicting extension io.tidepool.ext:tidepool-build-notes: 0.8.0 vs 1.0.0 in ./mvn-copy/apache-maven-3.10.0/conf/extensions.xml
[WARNING]
[WARNING] Order of core extensions precedence is project > user > installation. Selected extensions are:
[WARNING] * io.tidepool.ext:tidepool-build-notes:1.0.0

The same extension, with the same version, in the project and in the user file gave no warning: the build ran with one copy, and -X printed the two source files under Configured core extensions (in precedence order) and the single io.tidepool.ext:tidepool-build-notes:1.0.0 under Selected core extensions (in loading order). The user-wide and installation-wide files are for what a team or a machine wants on every build, for example an extension that records timing for all projects; the project file is for what one project needs, and, since it is committed, is the only one that makes the build repeatable on other machines.

Build extensions

Declared in the POM

A POM declares an extension in build/extensions. It is resolved while the project is read, and it is in effect for the project (and for the children that inherit the declaration), but only from that point on. The same JAR declared this way, in the parent POM, behaves differently from a core extension:

lab3/pom.xml (added to the parent POM)
<build>
  <extensions>
    <extension>
      <groupId>io.tidepool.ext</groupId>
      <artifactId>tidepool-build-notes</artifactId>
      <version>1.0.0</version>
    </extension>
  </extensions>
</build>
$ mvn compile   (Maven 3.10.0; the output of Maven 3.9.16 and 4.0.0-rc-7 had the same lines)
[INFO] [tidepool-build-notes] 3 project(s) read, class loader ClassRealm[extension>io.tidepool.ext:tidepool-build-notes:1.0.0, parent: jdk.internal.loader.ClassLoaders$AppClassLoader@5bc2b487]
[INFO] [tidepool-build-notes]   io.tidepool:tidepool:pom:1.0.0
[INFO] [tidepool-build-notes]   io.tidepool:tidepool-model:jar:1.0.0
[INFO] [tidepool-build-notes]   io.tidepool:tidepool-engine:jar:1.0.0
[INFO] BUILD SUCCESS
[INFO] [tidepool-build-notes] session ended

Three things are different from the core extension. The class loader is extension>io.tidepool.ext:tidepool-build-notes:1.0.0, a build extension realm (Maven 4.0.0-rc-7 appended a suffix of digits to the realm id). The line session started is missing: the session had started long before the POM was read. And the spy printed no mojo execution(s) started line either: the spy is registered when Maven starts, so only core extensions can be spies. The participant’s afterProjectsRead and afterSessionEnd did run, because lifecycle participants of build extensions are called once they are known. A build extension is therefore the right place for things that belong to one project (a packaging, a Wagon provider), and the wrong one for anything that must act on Maven’s own start.

(On Maven 4.0.0-rc-7 this experiment also printed Unable to find the root directory. Create a .mvn directory in the root directory or add the root="true" attribute on the root project's model — the lab project had no .mvn directory for that run. See What’s New in Maven 4.)

Plugins with <extensions>true</extensions>

A plugin can also carry an extension. When the plugin is declared with <extensions>true</extensions>, Maven loads the plugin in a build extension realm instead of a plugin realm, and registers the Plexus/Sisu components of its JAR — packaging definitions, lifecycle mappings and artifact handlers, for example. This is how a plugin adds a packaging type: a POM with <packaging>bundle</packaging> is only understood once the plugin that defines bundle is loaded as an extension. The official guide uses the Apache Felix bundle plugin as its example. In the lab tidepool-model became an OSGi bundle, first without the flag:

lab5/pom.xml (the packaging and the plugin)
<packaging>bundle</packaging>
...
<plugin>
  <groupId>org.apache.felix</groupId>
  <artifactId>maven-bundle-plugin</artifactId>
  <version>6.2.0</version>
</plugin>
$ mvn package   (Maven 3.10.0 and 3.9.16)
[ERROR] Some problems were encountered while processing the POMs:
[ERROR] Unknown packaging: bundle @ line 6, column 14

Maven knows the project’s packaging when it reads the POM, which is before it loads any plugin. Adding <extensions>true</extensions> to the plugin makes Maven load the plugin first and register its packaging:

<plugin>
  <groupId>org.apache.felix</groupId>
  <artifactId>maven-bundle-plugin</artifactId>
  <version>6.2.0</version>
  <extensions>true</extensions>
</plugin>
$ mvn package   (Maven 3.10.0; Maven 3.9.16 and 4.0.0-rc-7 ended with BUILD SUCCESS as well)
[INFO] -------------------------------[ bundle ]-------------------------------
[INFO] --- bundle:6.2.0:bundle (default-bundle) @ tidepool-model ---
[INFO] BUILD SUCCESS
$ unzip -p target/tidepool-model-1.0.0.jar META-INF/MANIFEST.MF | head -9
Manifest-Version: 1.0
Build-Jdk-Spec: 26
Bundle-ManifestVersion: 2
Bundle-SymbolicName: io.tidepool.model
Bundle-Version: 1.0.0
Created-By: Apache Maven Bundle Plugin 6.2.0
Export-Package: io.tidepool.model;version="1.0.0"
Import-Package: java.lang,java.lang.invoke,java.lang.runtime
Require-Capability: osgi.ee;filter:="(&(osgi.ee=JavaSE)(version=21))"

The packaging is defined by a LifecycleMapping component in the plugin JAR, in the file META-INF/plexus/components.xml. Its beginning shows the binding the packaging bundle gives: the same plugins as jar up to test, and the bundle plugin’s bundle goal in package (the build-lifecycle page explains the standard bindings, Build Lifecycle):

<component>
  <role>org.apache.maven.lifecycle.mapping.LifecycleMapping</role>
  <role-hint>bundle</role-hint>
  <implementation>org.apache.maven.lifecycle.mapping.DefaultLifecycleMapping</implementation>
  ...
      <package>org.apache.felix:maven-bundle-plugin:bundle</package>
      <install>
        org.apache.maven.plugins:maven-install-plugin:install,
        org.apache.felix:maven-bundle-plugin:install
      </install>

(The experiment pinned the versions of the standard plugins in pluginManagement. Without that, a lifecycle mapping that names a plugin without a version makes Maven look for the latest release that its own version can run, and print one has unmet prerequisites block for each newer plugin it has to reject; it works, but the output is long.) A plugin that needs the flag says so in its documentation, as the Felix bundle plugin does.

Wagon providers

The oldest use of build extensions is adding a Wagon provider: the library of transport protocols that Maven used before Resolver’s own HTTP transport (the Repositories, Mirrors & Repository Managers page has the transport table). Maven 3.10.0 ships the Resolver file and apache (HTTP) transports and the Wagon transport as a bridge, but the Wagon bridge needs a provider for each other protocol. A project that deploys to an ftp: URL, declared in distributionManagement, fails on all three releases because no transport knows the protocol:

$ mvn deploy   (Maven 3.10.0, repository ftp://localhost:2121/releases)
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-deploy-plugin:3.2.0:deploy (default-deploy) on project tidepool-model: Failed to deploy artifacts/metadata: Cannot access ftp://localhost:2121/releases with type default using the available connector factories: BasicRepositoryConnectorFactory: Cannot access ftp://localhost:2121/releases using the registered transporter factories: ApacheTransporterFactory, FileTransporterFactory, WagonTransporterFactory -> [Help 1]

Adding the provider as a build extension, as the guide does, makes the protocol known:

<build>
  <extensions>
    <extension>
      <groupId>org.apache.maven.wagon</groupId>
      <artifactId>wagon-ftp</artifactId>
      <version>3.5.3</version>
    </extension>
  </extensions>
</build>
$ mvn deploy   (Maven 3.10.0, 3.9.16 and 4.0.0-rc-7 gave the same message, the artifact name differs only on 4.0.0-rc-7: the jar before the pom)
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-deploy-plugin:3.2.0:deploy (default-deploy) on project tidepool-model: Failed to deploy artifacts: Could not transfer artifact io.tidepool:tidepool-model:pom:1.0.0 from/to tidepool-ftp (ftp://localhost:2121/releases): Password not specified for repository tidepool-ftp -> [Help 1]

The build still fails — the lab has no FTP server and no credentials, and nothing was sent — but it fails for a different reason: the ftp: protocol is now known and Maven asks for the credentials of the server tidepool-ftp (see settings.xml & Password Encryption). Deploying to a real FTP, SCP or WebDAV server is not run here. The same mechanism provides wagon-ssh, wagon-webdav-jackrabbit and others, and also the Wagon providers that a site deployment needs (see Site & Reporting).

Exported packages and artifacts

By default what a core extension contains is hidden from plugins: the API realm exposes only the packages that the extension exports. An extension JAR can contain the descriptor META-INF/maven/extension.xml that names them. The Takari Polyglot YAML extension (used further below) is a real example:

<extension>
  <exportedPackages>
    <exportedPackage>org.sonatype.maven.polyglot</exportedPackage>
    <exportedPackage>org.sonatype.maven.polyglot.execute</exportedPackage>
  </exportedPackages>

  <exportedArtifacts>
    <exportedArtifact>io.takari.polyglot:polyglot-common</exportedArtifact>
  </exportedArtifacts>
</extension>

exportedPackages makes classes visible to the plugin realms; exportedArtifacts does the same for every package of an artifact (Maven uses this itself, in its own extension.xml of Maven core, to export the Resolver packages). An extension that provides an API for plugins, for example a Wagon provider that a plugin uses directly, needs the descriptor; an extension that only plugs components into the container, like the lifecycle participant above, does not. The Maven Core Extensions Reference lists what Maven exports from its own core.

Finding an extension: resolution and repositories

A core extension is resolved before Maven has read the project’s POM, so the repositories that the POM declares are not available to resolve it. What Maven can use at that point is Maven Central and the repositories of settings.xml, with the same mirrors and credentials as for any other download (Repositories, Mirrors & Repository Managers). The failure for a version that does not exist — a typo, or an extension that has not been installed in the local repository — is an early one, and the message differs by release because Maven 3.10 and 4 consult the remote repository filter (the page on Maven Resolver explains it):

$ mvn compile   (.mvn/extensions.xml asks for io.tidepool.ext:tidepool-build-notes:9.9.9)
3.10.0:
[WARNING] The POM for io.tidepool.ext:tidepool-build-notes:jar:9.9.9 is missing, no dependency information available
[ERROR] Error executing Maven.
[ERROR] Extension io.tidepool.ext:tidepool-build-notes:9.9.9 or one of its dependencies could not be resolved: Plugin io.tidepool.ext:tidepool-build-notes:9.9.9 or one of its dependencies could not be resolved:
	prefixes: Path io/tidepool/ext/tidepool-build-notes/9.9.9/tidepool-build-notes-9.9.9.jar NOT allowed from central
3.9.16:
[WARNING] The POM for io.tidepool.ext:tidepool-build-notes:jar:9.9.9 is missing, no dependency information available
[ERROR] Error executing Maven.
[ERROR] Extension io.tidepool.ext:tidepool-build-notes:9.9.9 or one of its dependencies could not be resolved: Plugin io.tidepool.ext:tidepool-build-notes:9.9.9 or one of its dependencies could not be resolved:
	Could not find artifact io.tidepool.ext:tidepool-build-notes:jar:9.9.9 in central (https://repo.maven.apache.org/maven2)

Notice that the message says Error executing Maven, not BUILD FAILURE: no project had been built yet, and there is no reactor summary. In both messages Plugin ... could not be resolved is the resolver’s wording for any artifact it resolves with the plugin machinery; the extension is resolved with the same code as a plugin. The 3.9.16 message is the classic one; on 3.10.0 and 4.0.0-rc-7 the Central prefixes file already says that the path io/tidepool/ext/... does not exist there, so Maven does not even ask. An extension published to your own repository manager is resolved through the settings mirror in the same way, which is also why a build behind a proxy that blocks Central needs the extension in the manager.

The extension catalogue

The Maven Extensions page lists the extensions that the Maven project maintains and the ones that other projects provide.

Maintained by the Maven project

Two extensions, at the time of the page’s last publication (2026-10-16):

  • Maven Build Cache Extension 1.3.0 (released 2026-08-19): incremental builds and a local and remote cache of build results. It is a core extension; its configuration, the cache key and the remote cache are on Build Performance: Cache, Parallel Builds & mvnd. Its JAR contains the Sisu index META-INF/sisu/javax.inject.Named and no extension.xml.

  • Maven Enforcer Extension: defines rules globally, without POM inheritance. The catalogue table lists 3.5.0; Central has 3.6.3, the version of the Enforcer plugin, and 3.6.3 was used here.

The Enforcer extension runs the Enforcer plugin’s enforce goal at the start of the build, for every project, with rules read from the file .mvn/enforcer-extension.xml (the name is the constant of the extension class; the Maven page on the extension only has a usage link). The core extension is declared like any other:

lab7/.mvn/extensions.xml
<?xml version="1.0" encoding="UTF-8"?>
<extensions xmlns="http://maven.apache.org/EXTENSIONS/1.1.0">
  <extension>
    <groupId>org.apache.maven.extensions</groupId>
    <artifactId>maven-enforcer-extension</artifactId>
    <version>3.6.3</version>
  </extension>
</extensions>
lab7/.mvn/enforcer-extension.xml
<?xml version="1.0" encoding="UTF-8"?>
<extension>
  <executions>
    <execution>
      <id>tidepool-baseline</id>
      <configuration>
        <rules>
          <requireMavenVersion>
            <version>[3.9.0,)</version>
          </requireMavenVersion>
          <requireJavaVersion>
            <version>[21,)</version>
          </requireJavaVersion>
        </rules>
      </configuration>
    </execution>
  </executions>
</extension>
$ mvn compile   (Maven 3.10.0; Maven 3.9.16 and 4.0.0-rc-7 printed the same enforcer lines)
[INFO] --- enforcer:3.6.3:enforce (tidepool-baseline) @ tidepool ---
[INFO] Rule 0: org.apache.maven.enforcer.rules.version.RequireMavenVersion passed
[INFO] Rule 1: org.apache.maven.enforcer.rules.version.RequireJavaVersion passed
[INFO] --- enforcer:3.6.3:enforce (tidepool-baseline) @ tidepool-model ---
...

The enforce execution ran for each of the three projects; the rule results were printed for the first one only. Raising the Maven range to [3.11,) shows what a failing rule looks like:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-enforcer-plugin:3.6.3:enforce (tidepool-baseline) on project tidepool:
[ERROR] Rule 0: org.apache.maven.enforcer.rules.version.RequireMavenVersion failed with message:
[ERROR] Detected Maven Version: 3.10.0 is not in the allowed range [3.11,).
...
[INFO] tidepool-model ..................................... SKIPPED
[INFO] tidepool-engine .................................... SKIPPED
[ERROR] tidepool ........................................... FAILURE [  0.208 s]

The rules and the plugin are described on Enforcer; the extension is the way to apply them to a project whose POMs one cannot change, or to every project of an organisation through the user-wide or installation-wide extensions.xml and the .mvn directory that a template provides.

Outside the Maven land

The catalogue’s second table lists extensions from other maintainers. The ones below are named there; apart from Polyglot, they were not run here: they need accounts, servers or services that the lab does not have, and their documentation is where their configuration lives.

Extension Maintainer What it is (from the catalogue) and how it is loaded

polyglot

Takari

A set of extensions that allow the POM model to be written in other dialects than XML. Run here with YAML, below.

smart-builder

Takari

A replacement for the scheduling of the projects of a multi-module build (see Multi-Module Builds & the Reactor). Not run here.

profiler, maven-timeline

Takari, Jean-Christophe Gay

Event spies that record the time taken by each mojo of the build, or produce a time line of the phases and goals. Not run here. The spy in this page is the same idea in a few lines.

notifier

Jean-Christophe Gay

Sends a status notification at the end of the build. Not run here.

opentelemetry-maven-extension

The OpenTelemetry project

Instruments the build and emits traces that observability tools (Jaeger and commercial products) can show. Not run here.

jfr-maven-extension

Philippe Marschall

Generates Java Flight Recorder events for a Maven build. Not run here.

Develocity (commercial)

Gradle Inc.

Captures build insights (build scans), and provides local and remote build caching, distributed test execution and predictive test selection for builds connected to a Develocity installation. Not run here: it needs a server (the free build scans service is described in its own documentation).

Polyglot with YAML. The Takari Polyglot YAML extension io.takari.polyglot:polyglot-yaml:0.8.2 (the latest on Central) is declared in .mvn/extensions.xml exactly like the extension above, and then a file pom.yaml replaces pom.xml:

lab8/pom.yaml
modelVersion: 4.0.0
groupId: io.tidepool
artifactId: tidepool-model
version: 1.0.0
properties:
  maven.compiler.release: '21'
  project.build.sourceEncoding: UTF-8
  project.build.outputTimestamp: '2026-10-16T00:00:00Z'
$ mvn compile   (Maven 3.10.0; no pom.xml in the directory)
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug release 21] to target/classes
[INFO] BUILD SUCCESS

Maven 3.10.0 and 3.9.16 built the project from pom.yaml, and help:effective-pom reported Effective POM for project 'io.tidepool:tidepool-model:jar:1.0.0'. Maven 4.0.0-rc-7 did not: with the same extension file it answered The goal you specified requires a project to execute but there is no POM in this directory, so this Takari release does not work there. What Maven 4 changed about the POM is on Maven 4: POM Model & the Consumer POM.

The same idea in Ant and Gradle

Ant’s extension points are its antlibs and the class path of Ant itself: JARs in ANT_HOME/lib, in ~/.ant/lib or named with -lib are loaded into Ant’s own class loader, and a task library that is on the build’s class path is used through an XML namespace; see Antlibs & Namespaces and Listeners, Loggers & Input Handlers for the equivalent of an event spy. Gradle has no distinction between plugins and extensions in this sense: a plugin applied in settings.gradle.kts or in an init script can hook the whole build (the Gradle and Settings objects take lifecycle listeners and build services), and a plugin in buildSrc or an included build is loaded before the build script; see Init Scripts & the Tooling API and Binary Plugins. In Maven the line between the two is drawn by when the code is loaded and where its class loader hangs in the graph.

Maven 3.9, 3.10 and 4: what differed

Observation 3.9.16 3.10.0 4.0.0-rc-7

.mvn/extensions.xml with the Maven 3 extension (JSR-330 javax.inject)

loaded

loaded

loaded

~/.m2/extensions.xml, $MAVEN_HOME/conf/extensions.xml

ignored (user-wide file)

loaded; precedence project, user, installation

loaded (both files)

-Dmaven.ext.class.path

loaded, realm maven.ext

loaded, realm maven.ext

loaded, realm maven.ext

JAR in lib/ext

not tested

loaded into plexus.core

not tested

Build extension realm id

extension>g:a:v

extension>g:a:v

extension>g:a:v plus a numeric suffix

Takari Polyglot YAML 0.8.2

built the project

built the project

there is no POM in this directory

Failure of a missing extension

Could not find artifact ... in central

prefixes: Path ... NOT allowed from central

prefixes: Path ... NOT allowed from central

Classic problems

  • The extension seems to do nothing. The JAR has no Sisu index (META-INF/sisu/javax.inject.Named), or the class is not annotated. Unzip the JAR and look; the old sisu-maven-plugin 0.3.5 from the official guide produced no index in the lab.

  • NoClassDefFoundError or ClassCastException between the extension and Maven. The extension bundled a copy of a class that Maven’s core realm already has (maven-core, SLF4J, Resolver). Declare those with provided scope and compile against the Maven release you target.

  • The extension runs, but its first lines are missing. It was declared in the POM (a build extension), and the part of Maven that it wants to change had already run. Move it to .mvn/extensions.xml.

  • "Unknown packaging" or "Unsupported Protocol". A plugin that defines a packaging needs <extensions>true</extensions>, and a transport needs the Wagon provider as a build extension (or an apache or file repository URL).

  • The extension cannot be found. Core extensions are resolved before the POM is read; use Central or a repository from settings.xml, and check the local repository’s _remote.repositories records when the artifact was installed by hand (the repositories page explains them).

  • A teammate’s build does not use the extension. It was in ~/.m2/extensions.xml, a file that is not part of the project.

  • A plugin cannot see the extension’s classes. The extension did not export the package in META-INF/maven/extension.xml.

Where the books are outdated

The Maven 2 book of the bibliography (Maven 2.2) predates .mvn/extensions.xml and the API class loader, which Maven 3.3.1 added; before that the only choices were lib/ext and the command line, as the official guide recalls. Maven 3.10 adds user-wide and installation-wide files. Maven 2 itself has been end of life since 2014.

Summary

  • An extension changes Maven itself and is loaded into the Maven process; a plugin adds goals and is loaded in an isolated realm.

  • Core extensions (.mvn/extensions.xml, and in 3.10 ~/.m2/extensions.xml and $MAVEN_HOME/conf/extensions.xml, lib/ext, -Dmaven.ext.class.path) load before the POMs are read and can hook the whole session. Build extensions (build/extensions, or a plugin with <extensions>true</extensions>) load with the project and are isolated from each other.

  • An extension is a Sisu component: JSR-330 annotations and a META-INF/sisu/javax.inject.Named index (check the JAR). Extend AbstractMavenLifecycleParticipant, AbstractExecutionListener or AbstractEventSpy; event spies need to be core extensions.

  • Precedence of core extensions in 3.10 is project, user, installation; the same extension is loaded once.

  • Core extensions are resolved without the POM’s repositories; Maven 3.10 and 4 may reject a path that Central’s prefixes file does not list.

  • The catalogue: Build Cache 1.3.0, the Enforcer extension 3.6.3, Polyglot (not for Maven 4.0.0-rc-7), Wagon providers, and third-party telemetry and cache extensions.