Packaging a Service
Choose between a thin JAR with a lib folder, an uber JAR and a Spring Boot JAR, and know what each means for builds, images and debugging.
java -jar target/orders-service.jar
# Thin JAR: MANIFEST.MF holds
# Main-Class: com.example.orders.Main
# Class-Path: lib/a.jar lib/b.jarA Java service ships as compiled classes plus every library they use. The JVM does not care how those files are arranged, but your deployment does: the layout decides how you start the process, how big each release is and how well container layers cache.
Three common layouts
- Thin JAR plus a lib folder. Your classes go in one JAR whose manifest names the
Main-Classand lists the dependency JARs inClass-Path, relative to the JAR itself. You ship the folder. Gradle'sapplicationplugin builds a similar layout, with start scripts that set the class path (installDist). - Uber (fat) JAR. The Maven Shade plugin unpacks every dependency into one JAR. One file is easy to copy, but files with the same path in two libraries collide, so service registrations under
META-INF/servicesmust be merged with a transformer, and signed JARs break. - Spring Boot JAR. The Boot plugin repackages your JAR so dependencies stay as nested JARs inside it and a launcher class loads them. Nothing is unpacked or merged, and
java -jar app.jarjust works.
Each one starts with java -jar. With -jar, the -cp option and the CLASSPATH variable are ignored; only the manifest counts. That surprises people who add a driver with -cp and then wonder why it is missing.
Put the version in the artifact
When something breaks at 3 a.m. you want to know exactly which build is running. Write the version into the manifest (Implementation-Version) and log it at startup; getClass().getPackage().getImplementationVersion() reads it back when the class was loaded from that JAR. Give the file a fixed name, such as orders-service.jar, so Dockerfiles and scripts do not need to know the version.
What the build should and should not do
Build once, then promote the same artifact from test to staging to production. Rebuilding per environment means the thing you tested is not the thing you deployed. Configuration comes from outside the artifact at run time, so the JAR must not contain database passwords or environment-specific URLs.
For containers, the layout matters for caching. Dependencies change rarely and your classes change on every commit. A layout that keeps them apart, such as a thin JAR with lib/ or an extracted Spring Boot JAR, lets the image reuse a large dependency layer and push only a few hundred kilobytes per release. An uber JAR is one layer that changes every time.
Check the result before shipping it: jar tf lists what is inside, and unzip -p app.jar META-INF/MANIFEST.MF prints the manifest.
Example
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
// Writes a thin JAR's manifest, then reads it back the way the java launcher does.
void main() throws IOException {
Path jar = Files.createTempFile("orders-service", ".jar");
try {
var manifest = new Manifest();
Attributes attrs = manifest.getMainAttributes();
attrs.put(Attributes.Name.MANIFEST_VERSION, "1.0");
attrs.put(Attributes.Name.MAIN_CLASS, "com.example.orders.Main");
attrs.put(Attributes.Name.CLASS_PATH, "lib/json-mapper.jar lib/db-pool.jar");
attrs.put(Attributes.Name.IMPLEMENTATION_VERSION, "1.4.0");
try (var out = new JarOutputStream(Files.newOutputStream(jar), manifest)) {
// A real build adds the compiled classes here.
}
try (var file = new JarFile(jar.toFile())) {
Attributes read = file.getManifest().getMainAttributes();
IO.println("Main-Class: " + read.getValue(Attributes.Name.MAIN_CLASS));
for (String entry : read.getValue(Attributes.Name.CLASS_PATH).split(" ")) {
IO.println("Class-Path entry: " + entry);
}
IO.println("Implementation-Version: " + read.getValue(Attributes.Name.IMPLEMENTATION_VERSION));
}
} finally {
Files.deleteIfExists(jar);
}
}
// Output:
// Main-Class: com.example.orders.Main
// Class-Path entry: lib/json-mapper.jar
// Class-Path entry: lib/db-pool.jar
// Implementation-Version: 1.4.0
When to use it
- Ship an internal reporting service as a thin JAR plus lib folder so each release only uploads the small application JAR.
- Package a Spring Boot order API as one executable JAR that operations can start with java -jar on any server with Java 25.
- Read Implementation-Version at startup and expose it in logs and a /version endpoint so incidents can be tied to an exact build.
More examples
Maven: a thin JAR with its dependencies in target/lib
After mvn package, target/orders-service.jar and target/lib/ together are the deliverable; copy both and run java -jar orders-service.jar. includeScope runtime leaves test and provided dependencies out of lib.
<build>
<!-- A fixed file name, so scripts and Dockerfiles do not track the version -->
<finalName>orders-service</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>com.example.orders.Main</mainClass>
<!-- Writes Class-Path: lib/a.jar lib/b.jar ... -->
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
<!-- Writes Implementation-Version from the project version -->
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-libs</id>
<phase>package</phase>
<goals><goal>copy-dependencies</goal></goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>Gradle: the application plugin
installDist produces a thin layout with a start script that builds the class path for you, which suits both plain servers and container images. In a Spring Boot project you would use the bootJar task instead.
plugins {
application
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
application {
mainClass = "com.example.orders.Main"
applicationDefaultJvmArgs = listOf("-XX:MaxRAMPercentage=75.0")
}
// ./gradlew installDist -> build/install/orders-service/bin/orders-service
// build/install/orders-service/lib/*.jar
// ./gradlew distZip -> the same layout as a zip in build/distributionsInspecting what you built
Looking inside the JAR before it ships catches a missing Main-Class or a test library that leaked into lib. Doing it once in CI as a smoke test is cheaper than discovering it in production.
# List the entries and print the manifest
jar tf target/orders-service.jar | head
unzip -p target/orders-service.jar META-INF/MANIFEST.MF
# A Spring Boot JAR keeps dependencies as nested JARs
jar tf target/orders-service.jar | grep BOOT-INF/lib | head -3
# Run it; -jar means -cp and CLASSPATH are ignored
java -jar target/orders-service.jar
Discussion