Why your Spring Boot app is one runnable file
You finish a Spring Boot service, run a single command, and a full web server starts up:
java -jar app.jar
No Tomcat to install first. No classpath to spell out by hand. No WEB-INF folder dropped into a separate application server. One file, one command, a running app that answers HTTP on a port.
That single file is what this article is about. We will open it up and see what is inside, understand why it is built that way, and then look at the two packaging choices that decide how it runs and how quickly it ships: the fat jar and the layered jar. At the end we meet DevTools, the piece that keeps the write-code-and-see-it loop fast while you are still building.
Start with a plain question: why can't an ordinary jar already do this?
The problem: a jar cannot hold other jars
A normal Java application is not one jar. It is your code plus every library it depends on — often dozens of jars. To run it, you list all of them on the classpath:
java -cp app.jar:lib/jackson.jar:lib/tomcat.jar:... com.example.App
That is fragile. You have to ship a folder of jars alongside your own, and get the whole list right every time.
The obvious wish is to put everything into one jar. But standard Java has a hard limit here: the classpath cannot see a jar nested inside another jar. Java's built-in class loading knows how to read .class files from inside a jar, and it knows how to read a jar sitting on the filesystem — but it does not know how to reach into app.jar, find jackson.jar inside it, and load classes out of that. Nested jars are invisible to it.
So "just one file" is not something plain Java hands you. Something has to make it work. That something is the Spring Boot build plugin.
Inside the fat jar
When you run mvn package (or the Gradle equivalent), the Spring Boot plugin does an extra step called repackaging. It takes the plain jar your compiler produced and rebuilds it into an executable jar — the thing people casually call a fat jar, because it is fat with all its dependencies inside.
Peek inside one and the layout is deliberate:
```plain text
app.jar
├── META-INF/
│ └── MANIFEST.MF
├── org/springframework/boot/loader/ ← Spring's own loader classes
├── BOOT-INF/
│ ├── classes/ ← your compiled code
│ └── lib/ ← every dependency, as nested jars
Three things share the file. Your own classes live under **`BOOT-INF/classes`**. Every library you depend on sits as an untouched jar under **`BOOT-INF/lib`**. And at the root sits a small set of Spring's own classes — the loader.
Notice what Spring did _not_ do. It did not melt all the libraries down and pour their `.class` files into one flat pile. The older "uber jar" approach did exactly that, and it caused real pain: two libraries shipping a file at the same path would silently overwrite each other, and once unpacked you could no longer tell which library a class came from. Spring keeps each dependency as its own intact jar. Cleaner — but now we are back to the nested-jar problem Java can't solve on its own. That is what the loader classes are for.
## The launcher that boots the jar
Open the manifest and you see the trick:
```plain text
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.example.MyApplication
When you type java -jar app.jar, the JVM runs whatever Main-Class names. That is not your application. It is Spring's JarLauncher, the entry point that knows how to handle the nested layout.
The launcher does two jobs. First it installs a custom classloader — a small piece of Spring code that does know how to read a jar-inside-a-jar, reaching into BOOT-INF/lib to load classes straight out of those nested jars. Then it reads Start-Class from the manifest, which points at your real application, and hands control over to your main method — now running with a classloader that can see everything.
So the boot sequence is: JVM starts the launcher → launcher builds a classloader that can read nested jars → launcher calls your main. From your code's point of view, every dependency is simply on the classpath and life is normal. The launcher quietly bridged the gap that plain Java left open.
That is the whole fat jar story. It is a brilliant way to run an app. The trouble starts when you want to ship one.
Why one big jar is slow to ship
Most Spring Boot apps run in a Docker image these days. A Docker image is built in layers — stacked, read-only slices of a filesystem. The reason layers exist is caching: when you rebuild an image, Docker reuses any layer whose inputs did not change and only rebuilds the ones that did. Unchanged layers are also skipped when pushing and pulling, so a rebuild that touches one small layer ships almost nothing.
Now picture the naive Dockerfile:
FROM eclipse-temurin:21-jre
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
The whole fat jar is copied in as a single layer. And here is the sting: your 30 MB of dependencies and your 200 KB of code are fused into that one file. Change a single line in a controller, rebuild, and because the file's bytes changed, Docker throws away the cached layer and re-ships the entire 30 MB — even though the dependencies did not move an inch.
The costs are lopsided. Dependencies are large and rarely change. Your code is tiny and changes constantly. Baking them into one layer means every code change pays the full weight of the dependencies. We want the opposite: the things that rarely change in their own layer, and the things that change often in another.
The layered jar
This is exactly what a layered jar gives you. Since Spring Boot 2.3, the repackaged jar can carry an index file, BOOT-INF/layers.idx, that sorts its contents into named layers, ordered from least likely to change to most likely:
```plain text
- dependencies ← released third-party libraries
- spring-boot-loader ← Spring's loader classes
- snapshot-dependencies ← -SNAPSHOT libraries (change more often)
- application ← your classes and resources ```
The ordering is the whole point. Your dependencies almost never change between builds, so that layer stays cached for weeks. Your application code changes every commit, so only that thin layer is rebuilt and re-shipped.
To use the layers you first pull them out of the jar. The jar can unpack itself:
java -Djarmode=layertools -jar app.jar extract
That produces one folder per layer — dependencies/, spring-boot-loader/, snapshot-dependencies/, application/ — each holding just its slice of the original jar.
A Dockerfile that respects the layers
Now the layers map cleanly onto Docker's caching. The trick is a multi-stage build: one stage extracts the layers, and a second stage copies them in — one COPY per layer, in change-frequency order.
# Stage 1: extract the layers from the fat jar
FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
COPY app.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# Stage 2: assemble the image, layer by layer
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Each COPY becomes its own Docker layer. Because dependencies is copied before application, a code-only change invalidates only the last COPY — Docker reuses the cached dependency layer and rebuilds just the tiny application layer. The 30 MB stays put; only your 200 KB moves.
Notice the entry point is JarLauncher again — the same launcher from the fat jar. Even unpacked across folders, Spring still uses its loader to start your app. The layered jar did not change how the app runs; it only reorganised how it is stored so Docker can cache it well.
That covers building and shipping. The last piece is the loop you live in while writing the code in the first place.
DevTools: a faster inner loop
Restarting an app by hand after every change is the slow tax of development: save a file, stop the app, run it again, wait for Spring to start, click back to where you were. The dependency spring-boot-devtools exists to shrink that loop.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
With DevTools on the classpath, Spring watches your project's files. The moment your IDE recompiles a class, DevTools notices and automatically restarts the application. You save; a second later the running app already reflects the change. No manual stop-and-start.
The natural worry is: isn't restarting the whole app still slow? It would be — if DevTools did a cold restart. It does not, and the reason is clever.
Two classloaders make the restart quick
DevTools splits your app's classes across two classloaders. The base classloader loads the things that do not change while you work — the third-party libraries from your dependencies. The restart classloader loads the code you are actively editing — your own project classes.
When a file changes, DevTools throws away only the restart classloader and builds a fresh one, reloading just your classes. The base classloader — holding all the heavy libraries — is left untouched, and the JVM itself never stops.
That is why a DevTools restart feels near-instant while a cold start takes several seconds. A cold start reloads everything: the JVM boots, every library class is read and verified again from scratch. DevTools reloads only the small, changing half and keeps the expensive, stable half warm in memory. Same fresh application state, a fraction of the work.
The rest of the loop: caches off, browser refresh
DevTools smooths two more rough edges.
By default it overrides caching properties that make sense in production but get in your way during development. Template engines like Thymeleaf normally cache compiled templates; DevTools switches that caching off so a tweak to an HTML template shows up on the next request without even a restart.
It also runs a LiveReload server. Paired with a small browser extension, it refreshes the page in your browser automatically the instant the app restarts — so you are not even reaching for the reload button.
Why none of this reaches production
All of that convenience raises an obvious flag: you would never want auto-restart or disabled caches on a live server. DevTools handles this for you, on two levels.
First, it disables itself when it detects the app is running as a fully packaged jar — the java -jar app.jar path a real deployment uses. DevTools is active when you run from your IDE or the build tool, and dormant when it sees it is running from a packaged archive.
Second, the repackaging step strips DevTools out of the fat jar entirely, because you declared it optional. So the dependency is present while you develop and simply absent from the artifact you ship. The fast inner loop is a development-only tool, by design.
The one connected picture
Three ideas, one line of reasoning. The fat jar solves running: Spring's launcher and its custom classloader let a single file carry every nested dependency, so java -jar just works. The layered jar solves shipping: the same jar is sorted into layers by how often each part changes, so Docker caches the heavy, stable dependencies and re-ships only your thin, fast-moving code. And DevTools solves developing: two classloaders keep restarts near-instant, caches step out of your way, and the whole thing removes itself before it ever reaches production.
Build fat so it runs anywhere; layer it so it ships cheaply; lean on DevTools so getting there is quick.
Top comments (0)