From Modular JavaFX to Katabasis.app: My jlink and jpackage Journey #theEndOfTheJourney
In the previous article, I wrote about how Katabasis took shape as a modern Java application: modular, event-driven, built with JavaFX, and…
From Modular JavaFX to Katabasis.app: My jlink and jpackage Journey #theEndOfTheJourney

Nekyia: Katabasis
In the previous article, I wrote about how Katabasis took shape as a modern Java application: modular, event-driven, built with JavaFX, and backed by virtual threads.
But there is a moment in many desktop projects when architecture stops being an internal concern and becomes something more tangible.
That moment comes when you try to ship the application.
For me, that moment was the journey from a modular JavaFX project to a real macOS application bundle.
jlink and jpackage turned Katabasis from a JavaFX project into a real macOS application, but only after I stopped treating modules as theory and started respecting them as build-time reality.
This article is about that journey: the assumptions I had, the mistakes I made, the troubleshooting that followed, and what I learned while turning Katabasis into Katabasis.app.

Katabasis app
From “it runs in the IDE” to “it feels like an application”
Katabasis was already working as a JavaFX application inside the IDE.
That is a good milestone, but it is not the same thing as having an actual app.
There is a huge psychological difference between:
- a project that runs from your development environment
- and a real application bundle with its own launcher, runtime image, and platform-specific packaging
I wanted Katabasis to cross that line.
I wanted it to stop feeling like “some Java code with a UI” and start feeling like a real desktop application.
That naturally led me to two tools in the modern Java toolchain:
- jlink
- jpackage
They are related, but they do different jobs.
What jlink and jpackage actually do
Before using them seriously, it helped me to state the distinction clearly.
jlink builds a custom runtime image. In other words, it creates a trimmed-down Java runtime containing only the modules the application actually needs.
jpackage takes an application and a runtime image and packages them into a native distribution format for the operating system. In my case, on macOS, that meant generating an .app.
The flow for Katabasis became:
- Make the project truly modular
- Use jlink to create the runtime image
- Test the runtime image directly
- Use jpackage to produce Katabasis.app
That sounds clean in hindsight.
In practice, I had to earn it.
The first hard lesson: my modules were not really modules
At some point, I had already created module-info.java files and thought I had done the important part.
But jlink was not impressed.
It kept failing with errors that pointed toward the fact that the application was not really modular in the way the build expected it to be.
The mistake, in retrospect, was simple and slightly embarrassing:
I had placed module-info.java inside the resources folder instead of the Java source folder.
That means I had effectively treated the module descriptor as if it were just another file to be copied, rather than a Java source file that had to be compiled into the jar.
That distinction matters a lot.
For Java’s module system, module-info.java must live under:
[embed]
not under:
[embed]
When it sits in resources, Maven copies it, but it does not compile it into module-info.class. And if that does not happen, your jar may look modular from the source tree, while still behaving like an automatic module or even a plain old jar at packaging time.
That was exactly the trap I fell into.
It was one of those moments where the source code seems to say one thing, but the built artifact says another.
And jlink only cares about the built artifact.
The symptom that forced me to look deeper
Once packaging started failing, I had to stop staring at source files and start inspecting the jars themselves.
That turned out to be the right move.
One of the most useful debugging commands in the whole process was:
[embed]
Before I fixed the placement of module-info.java, the output made the problem visible.
Instead of describing real named modules, the jars were being treated as automatic modules, or showed that a proper module descriptor was missing.
That was the clue I needed.
The source tree had made me feel safe, but the jars told the truth.
That was one of the key lessons of the entire journey:
having a module-info.java file somewhere in the project is not the same thing as producing a truly modular jar.
To make sure the descriptor was really being compiled, I also checked the compiled classes directly:
[embed]
That was another useful reality check.
Once I moved the descriptors into src/main/java, cleaned the build, and rebuilt the project, the jars finally started behaving like real modules.

Katabasis as an app

Katabasis in finder
Modularity became real the moment the build enforced it
That mistake taught me something important.
As long as you are running code in the IDE, modularity can remain a bit abstract. You can convince yourself that the project is modular because the files look right, the packages are organized, and the code compiles.
But the moment you use jlink, modularity stops being aesthetic and becomes operational.
The build either sees real modules or it does not.
There is no room for hand-waving there.
That is why I now think of packaging as one of the best reality checks for modular Java. It forces you to respect the actual runtime structure of the application, not just the intention behind it.
The next reality check: java.desktop
Once I fixed the descriptor problem, the next issue appeared almost immediately.
This one was much more legitimate, and in a way, much more satisfying, because it meant the module system was now doing its job.
Katabasis used java.awt.Desktop in the UI layer in order to open the download location after completion. That worked fine conceptually, but in a modular application, using Desktop means depending on the java.desktop module.
And I had not declared it.
So the fix was straightforward:
requires java.desktop;
added to the UI module descriptor.
This was a great example of something the old classpath world tends to hide. Under the module system, dependencies are explicit. If you use something, you declare it.
That extra friction is not always pleasant, but it is often clarifying.
Verifying the jars before trying again
After those corrections, I rebuilt the project and inspected the jars again.
This time, the output finally matched the architecture I thought I had.
The module descriptors were real. The jars were truly modular. The build and the source tree were finally aligned.
That was the point where jlink began to make sense again.
And it was also the point where I became much less interested in “the project looks modular” and much more interested in “the artifacts prove it.”
Building the runtime image with jlink
For Katabasis, I used the JavaFX Maven plugin to generate the runtime image.
The command looked like this:
mvn -f katabasis-ui/pom.xml clean org.openjfx:javafx-maven-plugin:0.0.8:jlink -DskipTests
Once the modular issues were fixed, this step finally succeeded.
The plugin generated a runtime image under:
katabasis-ui/target/katabasis
and also produced a zip artifact for that image.
That moment mattered more than I expected.
Because for the first time, I was no longer just running the application as compiled project output. I had a dedicated runtime image with its own launcher.
To test it, I ran:
./katabasis-ui/target/katabasis/bin/katabasis
And it worked.
That was a very satisfying moment.
The application was still not packaged as a native macOS bundle yet, but it had already crossed an important line. It was now something closer to a real distributable runtime than a development-only build.
Why testing the runtime image first mattered
I am glad I tested the runtime image before going to jpackage.
That intermediate step gave me a clean checkpoint.
If the application failed there, the problem would likely be in modularity, the runtime image, or the launcher configuration.
If it succeeded there but failed later during packaging, I could isolate the issue much more easily.
That is one of the practical lessons I would now recommend to anyone doing this workflow:
do not jump directly from “the app runs in the IDE” to native packaging. Validate the jlink image first.
That step removes a lot of ambiguity.
From runtime image to Katabasis.app
Once the runtime image was working, the final step was jpackage.
The command I used was:
[embed]
This told jpackage to take the runtime image produced by jlink and turn it into a native macOS app image.
There was one small moment of confusion here too.
At first, it looked like nothing had been generated.
But the truth was much simpler: the output had gone exactly where I told it to go.
Because I ran the command from the project root and specified:
— dest “$(pwd)/dist”
the result appeared under:
dist/Katabasis.app
not under katabasis-ui/target.
That sounds obvious now, but at the time it was one of those human moments where you are looking in the wrong place and wondering whether the tool did anything at all.
The next time I reran the command, the clue became explicit: jpackage complained that the destination already existed.
That was when I realized the app had already been created.
And there it was:
Katabasis.app

Katabasis running as an app
The moment the project changed emotionally
There is a technical side to packaging, and then there is an emotional side.
The technical side is obvious: module descriptors, runtime images, launchers, packaging commands.
The emotional side is harder to describe, but it matters.
When I opened Katabasis.app, the project felt different.
It no longer felt like code that happened to display a window. It felt like an application.
That is not just vanity. It changes how you think about the project.
You start imagining other people using it. You start thinking more concretely about polish, icons, installation, packaging quality, and the final experience. The project stops being only an engineering artifact and starts becoming something closer to a product, even if it is still an alpha prototype.
That transition was one of the most satisfying parts of the entire Katabasis journey.
What this process taught me
The packaging journey reinforced several lessons.
First, modularity is only real when the build respects it. A source file in the wrong folder can silently turn a “modular” project into something that is only pretending to be modular.
Second, when debugging modular packaging, inspect the jars, not just the source tree. The built artifacts are what jlink and jpackage actually see.
Third, jlink and jpackage solve different problems. One shapes the runtime. The other shapes delivery.
Fourth, packaging is not a cosmetic final step. It is an architectural checkpoint. It exposes assumptions that the IDE often allows you to ignore.
And finally, getting to a native application bundle is worth it. Not just for distribution, but because it changes how the project feels in your hands.
Closing…
Katabasis did not become Katabasis.app through a single magic command.
It got there through a chain of small corrections and hard-earned clarity:
- understanding what jlink and jpackage each do
- realizing my modules were not truly modular
- moving module-info.java to the correct place
- inspecting the jars directly
- declaring java.desktop explicitly
- validating the runtime image before packaging
- and finally producing the app bundle itself
That sequence was messy enough to be real, and clean enough to be teachable.
And in the end, it was worth it.
Katabasis stopped being just a JavaFX project.
It became an app.
If you are working on a modular Java desktop application and have only run it from the IDE so far, I strongly recommend going one step further. Build the runtime image. Inspect the jars. Package the app. Let the build tell you the truth about your architecture.
“The code is written, the runtime is linked, and the app is packaged. The circle is complete. #theEndOfTheJourney”
JSanca
Platform Architect exploring the boundaries between simplicity and scalability
메타데이터
- post_id
- dca8bca84144
- slug
- from-modular-javafx-to-katabasis-app-my-jlink-and-jpackage-journey-theendofthejourney-dca8bca84144
- url
- https://medium.com/@jsanca/from-modular-javafx-to-katabasis-app-my-jlink-and-jpackage-journey-theendofthejourney-dca8bca84144
- canonical_url
- https://medium.com/@jsanca/from-modular-javafx-to-katabasis-app-my-jlink-and-jpackage-journey-theendofthejourney-dca8bca84144
- author_url
- https://medium.com/@jsanca
- status
- ok
- fetched_at
- 2026-07-11 08:58:46