Big things have small beginnings: taking Java from small to big with JBang
Today we will tour the java jungles using one single, simple, yet powerful, handgun called jbang, showing that bazookas like maven or gradle are a choice, not a necessity.
But what is jbang?
Well, imagine that you want to hello world.
All you need is a compiler/interpreter and some knowledge of the language. But what if you want a database hello world, or json-serialization hello world?
Unless the library for those already is present, either you write the code for it yourself, from scratch, or you do as most modern languages/platforms usually offers: a huge ready to use online library, just download and boom, bullseye.
In Java, most people would say to you something like it's dangerous to go alone, take this!, then then hand over to you a bunch of folders, a cabalistic xml file and explain that this is how java goes to look like for now on.
Well, this is how bureaucracy starts to enter in your veins. Now java is clumsy, verbose, even slow, useless without all those devices to keep it alive. But by wielding this large machinery, one can serve millions, go full enterprise, to de moon, and -- no.
Java doesn't need to be that way, either too naive or too complex.
This is where JBang enters and shows that, not only the Gordian Knot is easier to untie than expected, but the entire power of this entire ecosystem can be tamed with less effort and bureaucracy.
But don't take my word, let's see what is possible to really do with it.
Installation
Several options:
SDKMan!
First, install sdkman
sdk install jbang
asdf
First, install asdf
asdf plugin-add jbang
asdf install jbang latest
asdf global jbang latest
You can also check a list of alternative methods to get it in the project site.
Bash completon
Add the following line at the end of your .bashrc:
# JBang
. <(jbang completion)
export PATH=$PATH:$HOME/.jbang/bin
How to hello world
Direct eval
Make jbang eval java code directly from the command line:
jbang run -c "System.out.println(\"Hello World\");"
Create a simple script
Use jbang to initialize a simple script
jbang init Hello.java
The generated file goes like this:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
void main(String... args) {
IO.println("Hello World");
}
If you need a specific java version, pass the version as parameter in the init command:
jbang init --java=8 Hello.java
Then the Generated source file goes like this:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 8
import static java.lang.System.*;
public class Hello {
public static void main(String... args) {
out.println("Hello World");
}
}
Running
Call the entry script:
jbang Hello.java
One cool thing is that jbang downloads a suitable jdk based on what you
provides in the //JAVA <version> comment in the script.
Those comments are relevant, as we'll see over the samples.
Other exotic entry points
JBang also supports jshell scripts and markdown files as entry points, but let's not cover those modes here. Just check the official docs if you want to do some exotic things.
Dependency management
So far, jbang is a neat toy, but any simple java project nowadays must be able to consume maven dependencies or some sort of external library registry.
It can do that too!
Creating a script with dependencies
To init a script with dependencies:
jbang init --deps=com.esotericsoftware:minlog:1.3.1 Hello.java
The script will look like this:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
//DEPS com.esotericsoftware:minlog:1.3.1
// i modified the script a little to make use of the dependency
import com.esotericsoftware.minlog.Log;
void main(String... args) {
// IO.println("Hello World");
Log.info("This is a tiny log message.");
}
Run it:
sombriks@erebus 03 $ jbang Hello.java
[jbang] Resolving dependencies...
[jbang] com.esotericsoftware:minlog:1.3.1
[jbang] Dependencies resolved
[jbang] Building jar for Hello.java...
00:00 INFO: This is a tiny log message.
sombriks@erebus 03 $
Now we're talking.
Add more dependencies
In order to add more dependencies, either provide a comma-separated list of maven coordinates (or GAV as jbang refers to them), during the init command or add them by hand in the script:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
//DEPS com.esotericsoftware:minlog:1.3.1
//DEPS com.google.code.gson:gson:2.11.0
import com.esotericsoftware.minlog.Log;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.util.ArrayList;
record Todo(String description, boolean done){}
void main(String... args) {
Log.info("This is a tiny log message.");
var list = new ArrayList();
list.add(new Todo("Do the dishes",true));
list.add(new Todo("Walk the dog",false));
Gson gson = new GsonBuilder().setPrettyPrinting().create();
String jsonOutput = gson.toJson(list);
Log.info(jsonOutput);
}
Resources
Resources are useful for configurations, templates and all sort of blobs you application migh need to consume in order to do the jobs.
In jbang, resources are pretty straightforward. For example:
touch users.xml
touch User.java
rouch log4j2.xml
jbang init --deps \
tools.jackson.dataformat:jackson-dataformat-xml:3.0.3,\
org.apache.logging.log4j:log4j-api:2.26.1,\
org.apache.logging.log4j:log4j-core:2.26.1 \
Build.java
Users xml would be like this:
<Users>
<user id="1" name="Alice"/>
<user id="2" name="Bobb"/>
</Users>
In Buyild.jva, we'll use the //SOURCES comment for the extra java sources and
the //FILES for the resource files:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
//COMPILE_OPTIONS -parameters
//DEPS tools.jackson.dataformat:jackson-dataformat-xml:3.0.3
//DEPS org.apache.logging.log4j:log4j-api:2.26.1
//DEPS org.apache.logging.log4j:log4j-core:2.26.1
//SOURCES User.java
//FILES log4j2.xml
//FILES users.xml
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import tools.jackson.dataformat.xml.XmlMapper;
import tools.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
import tools.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import java.util.ArrayList;
record Users(
@JacksonXmlElementWrapper(useWrapping = false)
@JacksonXmlProperty(localName = "user")
ArrayList<User> user){}
public class Build {
private static final Logger LOG = LogManager.getLogger(Build.class);
public static void main(String... args) throws Exception {
LOG.info("Hello World");
var mapper = XmlMapper.builder().build();
var users = mapper.readValue(Build.class
.getResourceAsStream("users.xml"), Users.class);
LOG.info(users);
}
}
The //COMPILE_OPTIONS is just to make jackson behave. More on that later.
Both in //FILES and //SOURCES it's possible to indicate a list of
comma-separated file or folders with wildcards. (i.e. src/main/java/**/*.java).
Project layout
Unlike maven, jbang is not opinionated regarding how you should organize your project.
But that doesn't mean that everything should be inside one single java source.
This is not the case.
For example, for those who comes from a maven or gradle culture, the following setup is more that familiar:
mkdir -p src/main/{java,resources}
mkdir -p src/main/java/foo/bar/{configs,controllers,services,models}
touch src/main/java/foo/bar/TodoApp.java
touch src/main/java/foo/bar/configs/TodoCfg.java
touch src/main/java/foo/bar/controllers/TodoCtl.java
touch src/main/java/foo/bar/services/TodoSvc.java
touch src/main/java/foo/bar/models/Todo.java
touch src/main/resources/application.properties
jbang init --deps \
io.vertx:vertx-core:4.5.7,\
io.vertx:vertx-web:4.5.7,\
io.vertx:vertx-jdbc-client:4.5.7,\
io.agroal:agroal-pool:2.5,\
com.h2database:h2:2.2.224 \
Build.java
Next, add the //SOURCES and //FILES comment configurations in Build.java:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
//DEPS io.vertx:vertx-core:4.5.7
//DEPS io.vertx:vertx-web:4.5.7
//DEPS io.vertx:vertx-jdbc-client:4.5.7
//DEPS io.agroal:agroal-pool:2.5
//DEPS com.h2database:h2:2.2.224
//SOURCES src/main/java/**/*
//FILES src/main/resources/
void main(String... args) {
// simply pass the args to the application entry point
foo.bar.TodoApp.main(args);
}
The TodoApp class has no knowledge about how the project is built:
package foo.bar;
import io.vertx.core.Vertx;
import io.vertx.ext.web.Router;
import io.vertx.jdbcclient.JDBCPool;
import foo.bar.configs.TodoCfg;
import foo.bar.controllers.TodoCtl;
import foo.bar.services.TodoSvc;
public class TodoApp {
public static void main(String...args) throws Exception {
Vertx vertx = Vertx.vertx();
var cfg = new TodoCfg(vertx);
var pool = cfg.configurePool();
var todoSvc = new TodoSvc(pool);
var todoCtl = new TodoCtl(todoSvc);
Router router = Router.router(vertx);
router.get("/todos").handler(todoCtl::list);
router.post("/todos").handler(todoCtl::insert);
todoSvc.init().onSuccess(_ -> {
vertx.createHttpServer()
.requestHandler(router)
.listen(cfg.getServerPort());
});
}
}
A lower entry barrier
This sample aims to demonstrate how far jbang can take the project. It is possible to start with a simple, humble configuration and grow from that. This is a long lost skill in java ecosystem that i am glad to see it coming back in such elegant way.
In fact, The build system, with dependency resolution capabilities and other neat features should be native in the jdk itself.
Testing like a pro
So far we can build simple scripts, add resources, build complex infrastructures.
But does it do what it is supposed to do?
To answer this, the answer is to add tests to the jbang project.
Basic setup
Initialize the project as usual:
mkdir -p src/{main,test}/{java,resources}
mkdir -p src/{main,test}/java/xpto/baz
mkdir -p src/main/resources/META-INF
touch src/main/java/xpto/baz/{Main,TodoList,TodoItem}.java
touch src/main/resources/META-INF/persistence.xml
touch src/test/resources/init-test.sql
touch src/test/java/xpto/baz/MainTest.java
jbang init --deps \
org.hibernate.orm:hibernate-core:6.5.2.Final,\
com.h2database:h2:2.2.224 \
Build.java
In jbang entry point, configure the folder structure and call the actual main class:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
//DEPS org.eclipse.persistence:eclipselink:4.0.2
//DEPS com.h2database:h2:2.2.224
//SOURCES src/main/java/**/*
//FILES src/main/resources/
void main(String... args) throws Exception {
xpto.baz.Main.main(args);
}
The example is pretty straightforward, a simple JPA use case:
package xpto.baz;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import java.util.List;
public class Main {
private final EntityManagerFactory emf = Persistence //
.createEntityManagerFactory("default");
public List<TodoList> list() {
try (EntityManager em = emf.createEntityManager()) {
return em.createQuery("""
select t from TodoList t
""", TodoList.class).getResultList();
}
}
public TodoList add(String list, String task) {
try (EntityManager em = emf.createEntityManager()) {
em.getTransaction().begin();
TodoList todoList = new TodoList(list);
TodoItem todoItem = new TodoItem(todoList, task);
em.persist(todoList);
em.persist(todoItem);
em.flush();
em.clear();
em.getTransaction().commit();
todoList.getItems().add(todoItem);
return todoList;
}
}
public void close() {
emf.close();
}
public static void main(String... args) throws Exception {
var app = new Main();
app.add("today", "walk the dog");
List<TodoList> result = app.list();
result.forEach(IO::println);
app.close();
}
}
How to test
Nothing new so far, so, how to call a test runner in a jbang Scenario? Worth mentioning, how to keep the runtime classpath clean from test dependencies?
Easiest way: Add a Test entrypoint:
jbang init --deps \
org.junit.jupiter:junit-jupiter:5.11.0,\
org.junit.platform:junit-platform-launcher:1.11.0,\
org.hamcrest:hamcrest:3.0 \
Test.java
In the class Test.java, add the Build.java main jbang entry point as
a //SOURCES dependency, along with instructions to configure the test
classpath and the code to execute the tests and print the test summary:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
//DEPS org.junit.jupiter:junit-jupiter:5.11.0
//DEPS org.junit.platform:junit-platform-launcher:1.11.0
//DEPS org.hamcrest:hamcrest:3.0
//SOURCES Build.java
//SOURCES src/test/java/**/*
//FILES src/test/resources/
import org.junit.platform.launcher.Launcher;
import org.junit.platform.launcher.LauncherSession;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.engine.discovery.DiscoverySelectors;
void main(String... args) throws Exception {
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder
.request()
.selectors(DiscoverySelectors
.selectPackage("xpto.baz")) // put your tests under this package
.build();
SummaryGeneratingListener listener = new SummaryGeneratingListener();
try (LauncherSession session = LauncherFactory.openSession()) {
Launcher launcher = session.getLauncher();
launcher.registerTestExecutionListeners(listener);
launcher.execute(request);
}
var summary = listener.getSummary();
summary.printFailuresTo(new PrintWriter(System.err), 1);
summary.printTo(new PrintWriter(System.out));
System.exit(summary.getTestsFailedCount() > 0 ? 1 : 0);
}
And the tests are pretty standard java tests:
package xpto.baz;
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
class MainTest {
final Main main = new Main();
@Test
void shouldCreateTodo() {
var result = main.add("today", "doomscrolling");
assertThat(result, is(not(nullValue())));
assertThat(result.getId(), notNullValue());
assertThat(result.getId(), greaterThan(0L));
}
}
What about coverage
If a test serves to the purpose of confidently assert that the software should work, the coverage describes how much code should be working.
In any big boy project the minimum coverage lies about 80%, but this isn't a general rule. What is for sure is that covered code should not produce surprises. If it does, there is a flaky test.
The most popular coverage tool for java is jacoco.
To add it into our setup, add the dependency and create a extra coverage function to the test script:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
//DEPS org.junit.jupiter:junit-jupiter:5.11.0
//DEPS org.junit.platform:junit-platform-launcher:1.11.0
//DEPS org.hamcrest:hamcrest:3.0
//DEPS org.jacoco:org.jacoco.core:0.8.13
//SOURCES Build.java
//SOURCES src/test/java/**/*
//FILES src/test/resources/
import org.junit.platform.launcher.Launcher;
import org.junit.platform.launcher.LauncherSession;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.jacoco.core.runtime.IRuntime;
import org.jacoco.core.runtime.LoggerRuntime;
import org.jacoco.core.runtime.RuntimeData;
import org.jacoco.core.data.ExecutionDataStore;
import org.jacoco.core.data.SessionInfoStore;
import org.jacoco.core.analysis.Analyzer;
import org.jacoco.core.analysis.CoverageBuilder;
import org.jacoco.core.analysis.IClassCoverage;
import org.jacoco.core.analysis.ICounter;
import java.io.File;
import java.io.PrintWriter;
import java.io.IOException;
void main(String... args) throws Exception {
IRuntime runtime = new LoggerRuntime();
RuntimeData data = new RuntimeData();
runtime.startup(data);
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder
.request()
.selectors(DiscoverySelectors.selectPackage("xpto.baz"))
.build();
SummaryGeneratingListener listener = new SummaryGeneratingListener();
try (LauncherSession session = LauncherFactory.openSession()) {
Launcher launcher = session.getLauncher();
launcher.registerTestExecutionListeners(listener);
launcher.execute(request);
} finally {
runtime.shutdown();
}
var summary = listener.getSummary();
summary.printFailuresTo(new PrintWriter(System.err), 1);
summary.printTo(new PrintWriter(System.out));
coverage(data);
System.exit(summary.getTestsFailedCount() > 0 ? 1 : 0);
}
void coverage(RuntimeData data) throws IOException {
ExecutionDataStore executionData = new ExecutionDataStore();
SessionInfoStore sessionInfo = new SessionInfoStore();
data.collect(executionData, sessionInfo, false);
CoverageBuilder coverageBuilder = new CoverageBuilder();
// Agora passamos a estrutura correta (executionData) para o Analyzer
Analyzer analyzer = new Analyzer(executionData, coverageBuilder);
String classpath = System.getProperty("java.class.path");
for (String path : classpath.split(File.pathSeparator)) {
File file = new File(path);
if (file.isFile()
&& file.getName().endsWith(".jar")
&& file.getAbsolutePath().contains(".jbang")) {
analyzer.analyzeAll(file);
}
}
System.out.println("\n=== (JACOCO COVERAGE) ===");
System.out.printf("%-40s %-10s %-10s %-10s\n",//
"Class", "Covered", "Total", "% Coverage");
System.out.println("-".repeat(75));
int totalLines = 0;
int coveredLines = 0;
for (IClassCoverage classCoverage : coverageBuilder.getClasses()) {
if (classCoverage.getName().endsWith("Test")
|| classCoverage.getName().equals("Test")
|| classCoverage.getName().equals("Build")) {
continue;
}
ICounter lineCounter = classCoverage.getLineCounter();
int total = lineCounter.getTotalCount();
int covered = lineCounter.getCoveredCount();
double pct = total > 0 ? ((double) covered / total) * 100 : 0.0;
String name = classCoverage.getName().replace('/', '.');
System.out.printf("%-40s %-10d %-10d %-9.1f%%\n",//
name, covered, total, pct);
totalLines += total;
coveredLines += covered;
}
System.out.println("-".repeat(75));
double pct = totalLines > 0
? ((double) coveredLines / totalLines) * 100
: 0.0;
System.out.printf("%-40s %-10d %-10d %-9.1f%%\n", //
"TOTAL", coveredLines, totalLines, pct);
System.out.println("=".repeat(75));
}
That way both test and coverage reports will be provided to the project.
It's getting complex
Well, yes, but it comes in incremental steps.
jbang also offers an export tool, if you feel that the project should be managed in a more old-fashioned way, using gradle ofr maven.
Templates
You don't need always build jbang projects from scratch. It supports temaplates.
To create a jbang script using a template goes like this:
jbang init --template=cli Hello.java
Then you can test the command line interface lie this:
jbsng Hello.java
Packaging the project
Once your project is done, it't time to package it.
JBang offers a couple of ways to do that.
Export a project
Once you're done with scripting, you can export the script as a full-featured traditional maven or gradle project:
jbang init -t cli Hello.java
jbang export maven Hello.java
That creates a Hello folder with a maven project inside.
Export a (fat) jar
If everything is done and all you need is to spit out the packaged project:
jbang export portable Hello.java
This produces a jar and a lib folder, with all dependencies needed to run the app.
To avoid the inconvenience of carrying the lib folder around, export a single, all-inclusive jar file:
jbang export fatjar Hello.java
What is inside
In both cases, jar and lib folder and fatjar, the following command can describe what's actually inside the package:
jar tvf $(jbang info jar Hello.java)
Install as a local app
JBang also allows you to install a project as a local application.
First make sure that your terminal is properly set up:
jbang app setup
You can also add something like this in your .bashrc:
# JBang
. <(jbang completion)
export PATH=$PATH:$HOME/.jbang/bin
Now we're good to go:
jbang app install --name hellojbang Hello.java
Test it:
hellojbang
Congratulations, you just installed a command line tool.
Check the docs for more interesting ways to export and use scripts regarding package and distribution.
What about Kotlin
It's been a long time since the jvm became a true multi-language platform. One
iof the nicest options is jythopn jruby clojure groovy
scala javascript kotlin.
Entry point
At the moment, there is no jbang init Build.kt. So, create the script
yourself:
///usr/bin/env jbang "$0" "$@" ; exit $?
// let's call it App.kt
//KOTLIN 2.4.10
fun main(args: Array<String>) {
println("Hello ${args.firstOrNull() ?: "World"}")
}
Run it goes as expected:
jbang App.kt
All the rest goes as expected. just add the dependencies, some code and run it:
///usr/bin/env jbang "$0" "$@" ; exit $?
// let's call it App.kt
//JAVA 25+
//KOTLIN 2.4.10
//COMPILE_OPTIONS -jvm-target=25
//SOURCES *.kt
//DEPS io.javalin:javalin:7.2.2
//DEPS org.slf4j:slf4j-simple:2.0.16
//DEPS com.fasterxml.jackson.module:jackson-module-kotlin:2.17.2
//DEPS org.jetbrains.exposed:exposed-core:1.0.0
//DEPS org.jetbrains.exposed:exposed-jdbc:1.0.0
//DEPS com.h2database:h2:2.2.224
import io.javalin.Javalin
fun main(args: Array<String>) {
AppCfg.database()
val svc = TodoSvc()
val ctl = TodoCtl(svc)
val app = Javalin.create {
AppCfg.cors(it)
AppCfg.api(ctl, it.routes)
}
svc.init()
app.start(7070)
}
The //COMPILE_OPTIONS is added to make sure that the bytecode generated by
the kotlin compiler is compatible with the dependencies.
Going enterprise
Anyone aware of the JEE history would giggle a little just by thinking running those overly complex, full of xml configurations, ear/war java applications from a simple command line script.
Then Spring Boot emerged and ate the market.
But after the shock, over the years, [Jakarta EE][jee] applications fought complexity while kept a consistent API.
The spec is beautiful.
The (portable) application
The application is a simple, honest, JSON api:
mkdir -p src/app/{controllers,models,repositories,services}
touch src/app/App.java
touch src/app/controllers/TodoRes.java
touch src/app/models/Todo.java
touch src/app/repositories/TodoRepo.java
touch src/app/services/TodoSvc.java
mkdir src/META-INF
touch src/META-INF/{beans,persistence}.xml
And the implementation goes by the book:
// App.java
package app;
import jakarta.ws.rs.core.Application;
import jakarta.ws.rs.ApplicationPath;
@ApplicationPath("api")
public class App extends Application {}
// TodoRes.java
package app.controllers;
import app.models.Todo;
import app.services.TodoSvc;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
@Path("todos")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class TodoRes {
@Inject
private TodoSvc service;
@GET
public List<Todo> list() {
return service.listarTodas();
}
@POST
public Todo create(Todo todo) {
return service.salvar(todo);
}
}
All other classes goes with those well-known JEE annotations.
The persistence.xml is also tweaked to rely only on portable properties:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<persistence version="3.0"
xmlns="https://jakarta.ee"
xmlns:xsi="http://w3.org"
xsi:schemaLocation="https://jakarta.ee
https://jakarta.ee/persistence_3_0.xsd">
<persistence-unit name="default" transaction-type="JTA">
<class>app.models.Todo</class>
<properties>
<property name="jakarta.persistence.jdbc.driver"
value="com.h2database.Driver"/>
<property name="jakarta.persistence.jdbc.url"
value="jdbc:h2:mem:tododb;DB_CLOSE_DELAY=-1;MODE=LEGACY"/>
<property name="jakarta.persistence.jdbc.user" value="sa"/>
<property name="jakarta.persistence.jdbc.password" value=""/>
<property
name="jakarta.persistence.schema-generation.database.action"
value="create"/>
</properties>
</persistence-unit>
</persistence>
The beans.xml is an empty file.
Building the portable JEE artifact
Unlike everything we saw so far, we don't run the application straightforward.
Instead, we build a war file to be deployed into a jee server.
First, init the Portable.java source to compile the classes for us:
jbang init --java=21 --deps \
jakarta.platform:jakarta.jakartaee-web-api:10.0.0 \
Portable.java
The --java=21 flag matters due to some incompatibilities that some jee
servers has with java 25, used on all examples until now.
The Portable.java entry point looks like this:
///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS jakarta.platform:jakarta.jakartaee-web-api:10.0.0
//JAVA 21
//FILES META-INF/persistence.xml=src/META-INF/persistence.xml
//FILES META-INF/beans.xml=src/META-INF/beans.xml
//SOURCES src/**/*.java
public class Portable {
public static void main(String... args) {
System.out.println("Hello World");
}
}
Just one single //DEPS on the web profile jee specification.
Now we're good to go ahead and generate the jar:
jbang export portable --force Portable.java
The war format
Now it's time to perform a small convoluted step and smash the jar and create a war file:
mkdir -p ROOT/WEB-INF/classes
unzip ./Portable.jar -d ROOT/WEB-INF/classes/
cd ROOT
rm -f WEB-INF/classes/Portable.class
jar -cvfM ../ROOT.war *
cd ..
rm -rf ROOT
Now our artifact is ready to get deployed on any modern JEE AppServer.
Getting a JEE compatible app server
Now we need to find a server compatible with this application. This step should be easy.
Payara
We ca use jbang with no major issues to run our artifact:
jbang --java=21 \
fish.payara.extras:payara-micro:7.2026.2 \
--port 8080 \
--deploy ROOT.war
Wildfly Glow
For wildfly, just one extra step to generate a true portable jar file:
jbang org.wildfly.glow:wildfly-glow:1.4.0.Final \
scan ROOT.war \
--provision=BOOTABLE_JAR \
--add-ons=h2-database:default
java -jar ROOT-41.0.0.Final-bootable.jar
The state of JEE in the age of microservices
JEE barely passes this test, to be honest. The feeling that we're seeing a huge elephant riding a child-sized bicycle remains.
This is not an overall issue, just in this use case, where i am scaling from nothing to any arbitrary project size and structure using just jbang. And the fact that two of may JEE server i tried to run actually worked is a kind of good sign.
The spec goes in its own pace, it's open and keeps evolving.
Going enterprise, this time with more feeling
There are some fun facts about spring. For example:
The creator of spring framework started it as a book to point out the marvels of java enterprise edition. So, spring started as documentation first. Also, he started do disbelieve jee the more he studied it. Too convoluted, unnecessarily complex. It's a fun story.
Basic setup
The simplest setup goes like this:
jbang init --deps \
org.liquibase:liquibase-core:5.0.3,\
org.springframework.boot:spring-boot-starter-web:4.1.0,\
org.springframework.boot:spring-boot-starter-data-jpa:4.1.0,\
org.springframework.boot:spring-boot-starter-liquibase:4.1.0,\
org.springframework.boot:spring-boot-starter-test:4.1.0,\
org.junit.platform:junit-platform-launcher:6.1.2,\
org.hamcrest:hamcrest:3.0,\
com.h2database:h2:2.3.232 \
HelloSpring.java
mkdir -p src/app/{controllers,models,repositories,services}
mkdir -p resources/changelogs/2026/08/09
touch src/app/{App,AppTest}.java
touch src/app/controllers/TodoCtl.java
touch src/app/models/Todo.java
touch src/app/repositories/TodoRepo.java
touch src/app/services/TodoSvc.java
touch resources/{application,application-test}.yml
touch resources/changelogs/root-changelog.yml
touch resources/changelogs/2026/08/09/{1-create-database,2-test-data}.sql
This is a non-trivial hello world with spring and database migrations with liquibase. Scaffolded in a single (long) command line!
Our entry point
As usual, the main jbang script acts more as a dependency management than proper code, except for the code needed to run the tests:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
//DEPS org.liquibase:liquibase-core:5.0.3
//DEPS org.springframework.boot:spring-boot-starter-web:4.1.0
//DEPS org.springframework.boot:spring-boot-starter-data-jpa:4.1.0
//DEPS org.springframework.boot:spring-boot-starter-liquibase:4.1.0
//DEPS org.springframework.boot:spring-boot-starter-test:4.1.0
//DEPS org.junit.platform:junit-platform-launcher:6.1.2
//DEPS org.hamcrest:hamcrest:3.0
//DEPS com.h2database:h2:2.3.232
//SOURCES src/**/*.java
//FILES resources
import app.App;
import org.junit.platform.launcher.Launcher;
import org.junit.platform.launcher.LauncherSession;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import java.io.PrintWriter;
void main(String... args) {
if (args.length > 0 && "test".equals(args[0])) {
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder
.request()
.selectors(DiscoverySelectors.selectPackage("app"))
.build();
SummaryGeneratingListener listener = new SummaryGeneratingListener();
try (LauncherSession session = LauncherFactory.openSession()) {
Launcher launcher = session.getLauncher();
launcher.registerTestExecutionListeners(listener);
launcher.execute(request);
}
var summary = listener.getSummary();
summary.printFailuresTo(new PrintWriter(System.err), 1);
summary.printTo(new PrintWriter(System.out));
System.exit(summary.getTestsFailedCount() > 0 ? 1 : 0);
} else {
App.main(args);
}
}
The test itself need no knowledge of jbang or anything but spring:
package app;
import app.models.Todo;
import app.services.TodoSvc;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
@SpringBootTest
@ActiveProfiles("test")
public class AppTest {
@Autowired
private TodoSvc todoSvc;
@Test
public void deveBuscarDadosIniciaisDoLiquibaseViaService() {
List<Todo> todos = todoSvc.listarTodos();
assertThat(todos, is(notNullValue()));
assertThat(todos.size(), is(greaterThanOrEqualTo(2)));
String primeiroTitulo = todos.get(0).getTitulo();
assertThat(primeiroTitulo, containsString("Estudar JBang"));
}
}
Exporting
Export the project as a portable and things will work standalone:
jbang export portable HelloSpring.java
java -jar HelloSpring.jar
This approach, however, expects the lib folder next to the jar. Keep that in mind.
For the fatjar approach, follow these steps:
# under construction
The state of thing regarding jbang for serious spring projects
This is a marriage mad in heaven.
Spring scales up and down as good as jbang does and don't suffer from the rigid standarization that classic jee suffers.
Ir simply works.
Going native with JBang
This is a huge trend over the entire industry. Go native. There are benefits, of course, but they don't diminish the good parts of what java ecosystem delivers normally.
In fact, most java projects need special care to get properly transformed into native images, but i'll not discuss that here.
Anyway, jbang delivers a nice experience for those attempting to produce native things, as long as your environment is configured right.
Enter GraalVM
Use our friend sdkman to get the native guns:
sdk install java 25.2.4-graalce
Test the native powers this way:
rm Native Native.java
jbang init Native.java
jbang export native Native.java
./Native
A more complete example
Consider this setup for a first-class microservice in java:
jbang init --deps \
io.javalin:javalin:7.2.2,\
org.jdbi:jdbi3-core:3.54.0,\
com.fasterxml.jackson.core:jackson-databind:2.22.1,\
org.slf4j:slf4j-simple:2.0.13,\
com.h2database:h2:2.2.224,\
Build.java
mkdir -p src/main/{java,resources}
mkdir -p src/main/java/app/todos
mkdir -p src/main/resources/META-INF/native-image
touch src/main/java/app/App.java
touch src/main/java/app/todos/Todo{Ctl,Svc,Mdl}.java
touch src/main/resources/application.properties
touch src/main/resources/META-INF/native-image/resource-config.json
touch src/main/resources/META-INF/native-image/reflect-config.json
The Build.java goes as usual:
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25+
//DEPS io.javalin:javalin:7.2.2
//DEPS org.jdbi:jdbi3-core:3.54.0
//DEPS com.fasterxml.jackson.core:jackson-databind:2.22.1
//DEPS org.slf4j:slf4j-simple:2.0.13
//DEPS com.h2database:h2:2.2.224
//SOURCES src/main/java
//FILES src/main/resources
void main(String... args) throws Exception {
app.App.main(args);
}
The App looks like this:
package app;
import app.todos.TodoCtl;
import app.todos.TodoSvc;
import io.javalin.Javalin;
import static io.javalin.apibuilder.ApiBuilder.get;
import static io.javalin.apibuilder.ApiBuilder.path;
import static io.javalin.apibuilder.ApiBuilder.post;
public class App {
public static void main(String... args) throws Exception {
TodoSvc svc = new TodoSvc();
TodoCtl ctl = new TodoCtl(svc);
svc.init();
Javalin.create(c -> {
c.routes.apiBuilder(() -> {
path("todos", () -> {
get(ctl::list);
post(ctl::create);
});
});
})
.start(7000);
}
}
You get the idea. Run as usual:
jbang Build.java
The bundle config
Although javalin does its best to avoid reflection, which is a weak point on java native images, some dependencies need external resources, mostly locales, to work properly.
But the native image generation strips out those resources from the final binary and, because of that, we end up with nasty runtime errors.
To avoid this, the special configuration,
META-INF/native-image/resource-config.json defines every runtime bundle
that will be needed:
{
"bundles": [
{
"name": "jakarta.servlet.LocalStrings"
},
{
"name": "jakarta.servlet.http.LocalStrings"
}
],
"resources": {
"includes": [
{
"pattern": "application\\.properties"
},
{
"pattern": "\\Qapplication.properties\\E"
}
]
}
}
Why people avoid reflection in Native images
Another issue is reflection. If you really need reflection, it mjust be
authorized in META-INF/native-image/reflect-config.json:
[
{
"name": "org.h2.Driver",
"allPublicMethods": true,
"allDeclaredConstructors": true
},
{
"name": "app.todos.TodoMdl",
"allPublicMethods": true,
"allDeclaredConstructors": true
}
]
Imagine that, reflection became illegal in java!
Anyway, that's it, we're good to go ahead and create a native image from this code using jbang:
rm -rf lib Build Build.jar Build-fatjar.jar
jbang cache clear
jbang export native Build.java
Is it worth the trouble?
So, what do we get for our troubles?
jbang export portable Build.java
# ...
java -jar Build.jar
# ...
[main] INFO io.javalin.Javalin - Javalin started in 314ms \o/
Meanwhile, the native image:
./Build
# ...
[main] INFO io.javalin.Javalin - Javalin started in 64ms \o/
And even better startup times are possible.
One drawback are the compilation times. They make regular java builds look like a regular, interactive interpreter.
But boy it runs fast!
Conclusion
Alright, we did it, am homeric tour of several different places of java ecosystem. From hello world to the native images, passing by the old civilation of jee, the collective delirium of reactive programming, the modern enterprise java with spring boot, a lot of places.
And if you ask me, it went well, we did it. And all we needed was jbang.
To be honest, some samples where discarded because the technology was specifically designed to work properly only with maven or gradle, or due to reflection being illegal in (native) java, imagine that!
But most first-class, high-quality frameworks, ran smoothly with jbang because, in the end, all those libes need to run is java.
And boy, jbang serves java with elegance and class.
I didn't covered everythig. I didn't even covered properly the tings presented here.
But my case is made, the gun is loaded and ready to (j)bang.
Happy hacking!