ZetCode

Maven JAR Plugin

last modified November 13, 2026

In this article we work with the Maven JAR Plugin. We look at what a JAR built by Maven contains, add entries to the manifest, build a thin application JAR with a lib directory, create additional JARs with classifiers, and package the test classes. The examples use Maven 3.10, JAR plugin 3.5.1, and Java 25.

The JAR plugin creates the main artifact of projects with jar packaging. Its jar goal is bound to the package phase by default, so every mvn package runs it. The plugin packs the content of target/classes: the compiled classes and the resources copied from src/main/resources. It does not include dependencies; for JARs with dependencies see the Maven Shade Plugin and the Maven executable JAR articles.

The project

Our example counts words. It has a small API package, an implementation that uses Commons Lang, a command line entry point, and a benchmark that is useful during development but should not be shipped.

wordcount/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>wordcount</artifactId>
    <version>1.0.0</version>
    <name>Word count</name>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.21.0</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>6.1.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.5.1</version>
            </plugin>
        </plugins>
    </build>
</project>

The JAR plugin is part of the default lifecycle and works without any declaration. We pin its version so that the build does not depend on the default version of the Maven release in use.

wordcount/src/main/java/com/example/wordcount/api/WordCounter.java
package com.example.wordcount.api;

import java.util.Map;

public interface WordCounter {

    Map<String, Integer> count(String text);
}

The api package holds the interface.

wordcount/src/main/java/com/example/wordcount/SimpleWordCounter.java
package com.example.wordcount;

import com.example.wordcount.api.WordCounter;
import org.apache.commons.lang3.StringUtils;

import java.util.Map;
import java.util.TreeMap;

public class SimpleWordCounter implements WordCounter {

    @Override
    public Map<String, Integer> count(String text) {

        var counts = new TreeMap<String, Integer>();

        for (var word : StringUtils.split(text.toLowerCase(), " ,.;:!?")) {
            counts.merge(word, 1, Integer::sum);
        }

        return counts;
    }
}

The implementation splits the text with StringUtils.split from Commons Lang and counts the words in a sorted map.

wordcount/src/main/java/com/example/wordcount/Main.java
package com.example.wordcount;

public class Main {

    public static void main(String[] args) {

        var pkg = Main.class.getPackage();
        System.out.printf("%s %s%n", pkg.getImplementationTitle(),
                pkg.getImplementationVersion());

        var text = String.join(" ", args);
        new SimpleWordCounter().count(text)
                .forEach((word, n) -> System.out.printf("%-6s %d%n", word, n));
    }
}

The entry point prints the title and version of its package, then the word counts of its arguments.

var pkg = Main.class.getPackage();
System.out.printf("%s %s%n", pkg.getImplementationTitle(),
        pkg.getImplementationVersion());

The getImplementationTitle and getImplementationVersion methods read the Implementation-Title and Implementation-Version attributes of the JAR manifest. They return null when the attributes are missing.

wordcount/src/main/java/com/example/wordcount/dev/Benchmark.java
package com.example.wordcount.dev;

import com.example.wordcount.SimpleWordCounter;

public class Benchmark {

    public static void main(String[] args) {

        var text = "the quick brown fox jumps over the lazy dog ".repeat(100_000);
        long start = System.nanoTime();
        new SimpleWordCounter().count(text);
        System.out.printf("%d ms%n", (System.nanoTime() - start) / 1_000_000);
    }
}

The benchmark lives in the dev package. The project also has a JUnit test of SimpleWordCounter and a stopwords.txt resource.

The default JAR

We build the project and look inside the JAR.

The JAR contains the classes, the resource, and a META-INF/maven directory with a copy of the POM and a pom.properties file with the coordinates. Tools use these files to identify a JAR found on a classpath. The manifest has only basic attributes; JAR plugin 3.5 records the Java version used for the build. All entries are dated 1980-02-01, because Maven 3.10 builds reproducible JARs by default. The test class is not included.

$ mvn package
--- jar:3.5.1:jar (default-jar) @ wordcount ---
Building jar: /home/user/project/wordcount/target/wordcount-1.0.0.jar
BUILD SUCCESS
$ unzip -l target/wordcount-1.0.0.jar
Archive:  target/wordcount-1.0.0.jar
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  1980-02-01 00:00   META-INF/
       99  1980-02-01 00:00   META-INF/MANIFEST.MF
        0  1980-02-01 00:00   com/
        0  1980-02-01 00:00   com/example/
        0  1980-02-01 00:00   com/example/wordcount/
        0  1980-02-01 00:00   com/example/wordcount/api/
        0  1980-02-01 00:00   com/example/wordcount/dev/
        0  1980-02-01 00:00   META-INF/maven/
        0  1980-02-01 00:00   META-INF/maven/com.example/
        0  1980-02-01 00:00   META-INF/maven/com.example/wordcount/
     1975  1980-02-01 00:00   com/example/wordcount/Main.class
     1961  1980-02-01 00:00   com/example/wordcount/SimpleWordCounter.class
      280  1980-02-01 00:00   com/example/wordcount/api/WordCounter.class
     1056  1980-02-01 00:00   com/example/wordcount/dev/Benchmark.class
        9  1980-02-01 00:00   stopwords.txt
     1240  1980-02-01 00:00   META-INF/maven/com.example/wordcount/pom.xml
       55  1980-02-01 00:00   META-INF/maven/com.example/wordcount/pom.properties
---------                     -------
     6675                     17 files
$ unzip -p target/wordcount-1.0.0.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 25
Build-Jdk-Spec: 25

$ unzip -p target/wordcount-1.0.0.jar META-INF/maven/com.example/wordcount/pom.properties
artifactId=wordcount
groupId=com.example
version=1.0.0

Manifest entries

The archive element configures the manifest. We add a main class, the implementation entries, and two custom attributes, and we exclude the dev package.

pom.xml (JAR plugin configuration)
<configuration>
    <archive>
        <manifest>
            <mainClass>com.example.wordcount.Main</mainClass>
            <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
        </manifest>
        <manifestEntries>
            <Automatic-Module-Name>com.example.wordcount</Automatic-Module-Name>
            <Built-By>zetcode</Built-By>
        </manifestEntries>
    </archive>
    <excludes>
        <exclude>**/dev/**</exclude>
    </excludes>
</configuration>

The mainClass element writes the Main-Class attribute used by java -jar. With addDefaultImplementationEntries, the plugin writes the Implementation-Title and Implementation-Version attributes from the project name and version.

<manifestEntries>
    <Automatic-Module-Name>com.example.wordcount</Automatic-Module-Name>
    <Built-By>zetcode</Built-By>
</manifestEntries>

The manifestEntries element adds arbitrary attributes; the element name is the attribute name. Automatic-Module-Name gives a stable module name to a JAR without module-info.java when it is put on the module path.

<excludes>
    <exclude>**/dev/**</exclude>
</excludes>

The excludes patterns are relative to target/classes. The classes in the dev package are still compiled and can be run from the IDE, but they do not get into the JAR.

The benchmark is gone and the manifest has the new attributes. The JAR starts and prints the version from the manifest, but fails on the first use of Commons Lang: java -jar ignores the CLASSPATH variable and the -cp option, so the dependencies must be listed in the manifest.

$ mvn clean package
BUILD SUCCESS
$ unzip -l target/wordcount-1.0.0.jar | grep class
     1975  1980-02-01 00:00   com/example/wordcount/Main.class
     1961  1980-02-01 00:00   com/example/wordcount/SimpleWordCounter.class
      280  1980-02-01 00:00   com/example/wordcount/api/WordCounter.class
$ unzip -p target/wordcount-1.0.0.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 25
Build-Jdk-Spec: 25
Implementation-Title: Word count
Implementation-Version: 1.0.0
Main-Class: com.example.wordcount.Main
Automatic-Module-Name: com.example.wordcount
Built-By: zetcode

$ java -jar target/wordcount-1.0.0.jar a rose is a rose
Word count 1.0.0
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
	at com.example.wordcount.SimpleWordCounter.count(SimpleWordCounter.java:16)
...

Class-Path and the lib directory

The JAR plugin can write the runtime dependencies into the Class-Path attribute. The Dependency plugin copies them next to the JAR.

pom.xml (build plugins)
<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.5.1</version>
        <configuration>
            <archive>
                <manifest>
                    <mainClass>com.example.wordcount.Main</mainClass>
                    <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
                    <addClasspath>true</addClasspath>
                    <classpathPrefix>lib/</classpathPrefix>
                </manifest>
            </archive>
        </configuration>
    </plugin>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-dependency-plugin</artifactId>
        <version>3.11.0</version>
        <executions>
            <execution>
                <id>copy-libs</id>
                <phase>package</phase>
                <goals>
                    <goal>copy-dependencies</goal>
                </goals>
                <configuration>
                    <includeScope>runtime</includeScope>
                    <outputDirectory>${project.build.directory}/lib</outputDirectory>
                </configuration>
            </execution>
        </executions>
    </plugin>
</plugins>

With addClasspath, the manifest gets a Class-Path attribute listing the file names of the compile and runtime dependencies. The classpathPrefix is put in front of each name.

<goal>copy-dependencies</goal>

The copy-dependencies goal of the Dependency plugin copies the runtime dependencies into target/lib in the same package phase. Since it is declared after the JAR plugin, it runs after it. The two lists match, because both plugins use the same dependencies with the same file names.

The Class-Path entries are resolved relative to the location of the JAR, not to the current directory. The application thus runs from the project directory and from a dist directory with the JAR and the lib directory, which can be zipped and shipped. This is a thin JAR distribution: an update of the application replaces a small JAR, and the libraries stay separate files.

$ mvn clean package
--- jar:3.5.1:jar (default-jar) @ wordcount ---
--- dependency:3.11.0:copy-dependencies (copy-libs) @ wordcount ---
BUILD SUCCESS
$ unzip -p target/wordcount-1.0.0.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 25
Build-Jdk-Spec: 25
Class-Path: lib/commons-lang3-3.21.0.jar
Implementation-Title: Word count
Implementation-Version: 1.0.0
Main-Class: com.example.wordcount.Main

$ java -jar target/wordcount-1.0.0.jar a rose is a rose
Word count 1.0.0
a      2
is     1
rose   2
$ mkdir dist && cp -r target/wordcount-1.0.0.jar target/lib dist
$ find dist
dist
dist/wordcount-1.0.0.jar
dist/lib
dist/lib/commons-lang3-3.21.0.jar
$ java -jar dist/wordcount-1.0.0.jar a rose
Word count 1.0.0
a      1
rose   1

Additional JARs with classifiers

A project has one main artifact, but it can attach more files distinguished by a classifier. We add a second execution of the jar goal that packs only the API, and the test-jar goal, which packs the test classes.

pom.xml (JAR plugin executions)
<executions>
    <execution>
        <id>api-jar</id>
        <goals>
            <goal>jar</goal>
        </goals>
        <configuration>
            <classifier>api</classifier>
            <includes>
                <include>com/example/wordcount/api/**</include>
            </includes>
        </configuration>
    </execution>
    <execution>
        <id>test-jar</id>
        <goals>
            <goal>test-jar</goal>
        </goals>
    </execution>
</executions>

An execution without a phase element runs in the default phase of its goal, which is package for both goals. The classifier is added to the file name and to the coordinates of the artifact; other projects refer to it with a classifier element in the dependency. The includes patterns limit the content to the API package.

<goal>test-jar</goal>

The test-jar goal packs target/test-classes with the tests classifier. Other modules use it with <type>test-jar</type> and the test scope to share test utilities and fixtures.

All three JARs are built and later installed and deployed together. When the tests are not compiled, as with -Dmaven.test.skip=true, the test JAR is skipped. A module that depends on it then fails to resolve it, so -DskipTests, which compiles the tests but does not run them, is the safer option in such builds.

$ mvn clean package
--- jar:3.5.1:jar (default-jar) @ wordcount ---
Building jar: /home/user/project/wordcount/target/wordcount-1.0.0.jar
--- jar:3.5.1:jar (api-jar) @ wordcount ---
Building jar: /home/user/project/wordcount/target/wordcount-1.0.0-api.jar
--- jar:3.5.1:test-jar (test-jar) @ wordcount ---
Building jar: /home/user/project/wordcount/target/wordcount-1.0.0-tests.jar
BUILD SUCCESS
$ unzip -l target/wordcount-1.0.0-api.jar | grep class
      280  1980-02-01 00:00   com/example/wordcount/api/WordCounter.class
$ unzip -l target/wordcount-1.0.0-tests.jar | grep class
     1161  1980-02-01 00:00   com/example/wordcount/SimpleWordCounterTest.class
$ mvn clean package -Dmaven.test.skip=true
--- jar:3.5.1:test-jar (test-jar) @ wordcount ---
Skipping packaging of the test-jar
BUILD SUCCESS
$ ls target/*.jar
target/wordcount-1.0.0-api.jar
target/wordcount-1.0.0.jar

Entry timestamps

The timestamps of the JAR entries come from the project.build.outputTimestamp property. Maven 3.10 sets it to 1980-02-01 when the project does not. We can set it to the release date instead.

pom.xml (properties)
<properties>
    <maven.compiler.release>25</maven.compiler.release>
    <project.build.outputTimestamp>2026-11-13T10:00:00Z</project.build.outputTimestamp>
</properties>

The value is an ISO 8601 date and time. The Maven Release plugin updates it automatically during a release.

All entries have the given time, and two builds produce byte-identical JARs with the same SHA-256 checksum. Reproducible JARs let anyone verify that a published binary was built from the published sources.

$ unzip -l target/wordcount-1.0.0.jar | head -6
Archive:  target/wordcount-1.0.0.jar
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2026-11-13 10:00   META-INF/
       99  2026-11-13 10:00   META-INF/MANIFEST.MF
        0  2026-11-13 10:00   com/
$ sha256sum target/wordcount-1.0.0.jar
66a2f6bacc1fcee022fac76668d5ec7fa464e8547748bc55b3501071ae9eda04  target/wordcount-1.0.0.jar
$ mvn -q clean package && sha256sum target/wordcount-1.0.0.jar
66a2f6bacc1fcee022fac76668d5ec7fa464e8547748bc55b3501071ae9eda04  target/wordcount-1.0.0.jar

Useful options

ParameterDescription
archivemanifest and other archive settings
includes, excludesfiles to include or exclude
classifierclassifier of an additional JAR
classesDirectorydirectory to pack, target/classes by default
skipIfEmptydo not create a JAR without content
forceCreationrebuild the JAR even if it is up to date
outputTimestamptimestamp of the entries

Inside archive, the manifest element also knows addDefaultSpecificationEntries, addBuildEnvironmentEntries, and classpathLayoutType; the manifestFile element uses a prepared manifest file instead of generating one. The addMavenDescriptor element set to false leaves out the META-INF/maven directory.

Source

Apache Maven JAR Plugin

In this article we have worked with the Maven JAR Plugin. We have added manifest entries, built a thin JAR with a lib directory, created JARs with classifiers, and made the build reproducible.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Java tutorials.