Building an Executable JAR
Choose between a thin JAR with a lib folder, a shaded JAR and a Spring Boot JAR, and avoid the merging bugs that break start-up.
java -jar target/orders-service-1.0.0.jar # needs Main-Class in META-INF/MANIFEST.MF
unzip -p target/orders-service-1.0.0.jar META-INF/MANIFEST.MFThe JAR that mvn package or gradle build produces by default holds only your own classes and resources. java -jar needs two more things: a Main-Class entry in META-INF/MANIFEST.MF naming the class with main, and every dependency on the class path. Since java -jar ignores -cp and CLASSPATH, the dependencies have to be inside the JAR or listed in the manifest.
Three shapes
- Thin JAR plus
lib/: the manifest'sClass-Pathlists dependency JARs by relative path, and they are copied next to the JAR. Simple and transparent, but you ship a folder, not a file. Gradle'sapplicationplugin builds a close relative throughinstallDistanddistZip: alib/folder plus start scripts that set the class path, so the manifest needs noClass-Path. - Shaded (uber or fat) JAR: the Maven Shade plugin unpacks every dependency into one archive. One file to copy, but files with the same path in different JARs collide. In Gradle the Shadow plugin does the same job.
- Spring Boot JAR:
spring-boot-maven-plugin(goalrepackage) or the GradlebootJartask keeps dependency JARs intact underBOOT-INF/liband setsMain-Classto Boot's own launcher, with your class inStart-Class. Nothing is merged, so nothing collides.
What breaks in a shaded JAR
Many libraries register implementations in META-INF/services files for ServiceLoader; JDBC drivers do this for java.sql.Driver. If two JARs have the same services file and the shade step keeps only one, the other implementation silently disappears and you get "No suitable driver" or a missing JSON module at run time. Add the ServicesResourceTransformer. Signed dependencies carry signature files that no longer match the merged archive, which fails with SecurityException: Invalid signature file digest; exclude them. The Shade plugin also warns about overlapping classes: read those warnings, because the class that wins is arbitrary.
Containers
For Docker images, one big JAR is a poor fit: any code change rebuilds a layer that contains all the dependencies too. A thin JAR with lib/ lets dependencies sit in their own layer, cached between builds. Spring Boot can extract its JAR into layers with java -Djarmode=tools -jar app.jar extract --layers for the same reason.
Check what you built
Before blaming the code, look inside: jar --list --file target/app.jar shows the entries, and unzip -p target/app.jar META-INF/MANIFEST.MF shows the manifest. A missing Main-Class fails with "no main manifest attribute, in app.jar"; a missing dependency fails later with NoClassDefFoundError, often only on the first request that touches it.
Example
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version> <!-- or newer -->
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<!-- Writes Main-Class into the merged manifest -->
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.orders.App</mainClass>
</transformer>
<!-- Concatenates META-INF/services files instead of keeping one -->
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<filters>
<!-- Signatures from signed JARs are invalid once merged -->
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>When to use it
- Package a small worker service as one file that operations can start with java -jar on any server with a JDK.
- Fix a shaded service that loses its PostgreSQL driver because META-INF/services files were overwritten.
- Split a Spring Boot JAR into layers so Docker rebuilds only the application layer on code changes.
More examples
What java -jar does with the manifest
The manifest is the first entry in the archive, and its Main-Class attribute is the only thing that tells java -jar where to start. The class loader here sees only the JAR, just as java -jar ignores the outside class path.
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import javax.tools.ToolProvider;
void main() throws Exception {
Path dir = Files.createTempDirectory("jar");
Path src = dir.resolve("src/com/acme/orders/App.java");
Files.createDirectories(src.getParent());
Files.writeString(src, """
package com.acme.orders;
public class App {
public static void main(String[] args) {
System.out.println("orders-service started with " + String.join(" ", args));
}
}
""");
// javac -d classes ...
Path classes = dir.resolve("classes");
int rc = ToolProvider.getSystemJavaCompiler()
.run(null, null, null, "-d", classes.toString(), src.toString());
IO.println("javac exit: " + rc);
// What maven-jar-plugin or jar --main-class writes
Manifest manifest = new Manifest();
manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, "com.acme.orders.App");
Path jar = dir.resolve("orders.jar");
try (var out = new JarOutputStream(Files.newOutputStream(jar), manifest)) {
out.putNextEntry(new JarEntry("com/acme/orders/App.class"));
out.write(Files.readAllBytes(classes.resolve("com/acme/orders/App.class")));
out.closeEntry();
}
// What java -jar does: read Main-Class, load it from the JAR, call main
String mainClass;
try (var jf = new JarFile(jar.toFile())) {
jf.stream().map(JarEntry::getName).forEach(n -> IO.println("entry: " + n));
mainClass = jf.getManifest().getMainAttributes().getValue("Main-Class");
}
IO.println("Main-Class: " + mainClass);
try (var loader = new URLClassLoader(new URL[] { jar.toUri().toURL() }, null)) {
Class<?> app = Class.forName(mainClass, true, loader);
app.getMethod("main", String[].class)
.invoke(null, (Object) new String[] { "--port=8080" });
}
try (var walk = Files.walk(dir)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
}
}
// Output:
// javac exit: 0
// entry: META-INF/MANIFEST.MF
// entry: com/acme/orders/App.class
// Main-Class: com.acme.orders.App
// orders-service started with --port=8080A runnable distribution with Gradle
installDist copies the application JAR and every runtime dependency into lib/ and writes start scripts that set the class path for you. The plain JAR still holds no dependencies, so start the service through the bin script rather than with java -jar.
plugins {
application
}
application {
mainClass = "com.example.orders.App"
}
// Optional: records the entry point in the manifest too; java -jar on this
// JAR alone still fails, because the dependencies are not on its class path
tasks.jar {
manifest {
attributes("Main-Class" to "com.example.orders.App")
}
}
// ./gradlew installDist -> build/install/orders-service/bin and lib
// ./gradlew distZip -> build/distributions/orders-service-1.0.0-SNAPSHOT.zip
// build/install/orders-service/bin/orders-service --port=8080Building, inspecting and running
Reading the manifest settles most start-up failures in seconds. The layer extraction separates dependencies from application classes so a Docker build caches them independently.
./mvnw -B -ntp package
# What is inside, and what does the manifest say?
jar --list --file target/orders-service-1.0.0.jar | head
unzip -p target/orders-service-1.0.0.jar META-INF/MANIFEST.MF
# Run it; extra -cp entries would be ignored here
java -jar target/orders-service-1.0.0.jar --port=8080
# Spring Boot: split into layers for a Docker image
java -Djarmode=tools -jar target/orders-service-1.0.0.jar extract --layers --destination extracted
Discussion