Jenesis Get in touch

Why Java needs a new build system

You already wrote the build script.

Java builds have grown verbose. A pom.xml, a build.gradle.kts or a BUILD.bazel restates what the code already says - its name, its dependencies, its main class - and grows a block of configuration for every step beyond the defaults, until the build is a second program to maintain beside the first. Jenesis reads the build from the code instead: module-info.java names what the project requires, and its Javadoc carries any metadata. There is nothing else to write, and no build tool to download.

  • Supply-chain security, end to end
  • Embedded as source
  • Lean and fast
  • Uses the tools the JDK ships
  • Reads a pom.xml too
$ curl -fsSL https://get.jenesis.build | bash
$ cat sources/module-info.java
/**
 * @jenesis.release 25
 * @jenesis.main demo.app.Main
 */
module demo.app {
    requires org.slf4j;
    requires com.fasterxml.jackson.databind;
    requires info.picocli;
    requires static org.jspecify;
}
$ java build/jenesis/Make.java

Side by side

Fourteen common builds, in four tools.

Each section below is one build need, written for Jenesis, Maven, Gradle and Bazel. Every file shown was built and run with that tool. Where a tool needs a plugin, code by hand, or cannot do it at all, the section says so - Jenesis included.

Built and run on Java 25 with Jenesis 0.15.3, Maven 3.9.16, Gradle 9.8.0 and Bazel 9.2.0.

1 of 14

Hello, world

One class that prints a line, and nothing else. Before any feature comes into play, it shows what each tool asks for: the files you write, where the source goes, and how the build tool itself ships with the repository.

Jenesis reads the Java Module System's own descriptor, module-info.java, where two Javadoc tags name the Java release and the main class. The other tools each bring a build file of their own.

  • Jenesis Source Embedded in the repository as source: build/jenesis/, 2.3 MB. A clean build downloads nothing more: 189 files in total.
  • Maven Download The Maven Wrapper downloads a 9.4 MB binary release, and a clean build then 20 MB of plugins: about 13,400 files in total, counting those inside jars.
  • Gradle Download The Gradle Wrapper, a 47 KB jar in the repository, downloads a 152 MB binary release, and this build needs no plugin beyond it: about 102,800 files in total, counting those inside jars.
  • Bazel Not possible The repository cannot bootstrap Bazel: every machine installs Bazelisk first, 7 MB, which downloads the 66 MB Bazel binary .bazelversion names. A clean build then downloads 131 MB: about 76,300 files in total, counting those inside jars.
sources/module-info.java6 lines
/** * @jenesis.release 25 * @jenesis.main demo.hello.Main */module demo.hello {}
sources/demo/hello/Main.java8 lines
package demo.hello;public class Main {    public static void main(String[] args) {        System.out.println("Hello, world!");    }}
Terminal
$ java build/jenesis/Execute.java[COMPLETED] Finished in 2.65 secondsHello, world!

Worth knowing

  • The first run compiles build/jenesis/ into .jenesis/ once, as Maven and Gradle download their release on first use; later runs start from those classes.
  • The last section installs build/jenesis/. The terminal shows the build's last line.

2 of 14

A modular build in Java

A Java 25 application with four dependencies, written as a module. Its module-info.java is the Java Module System's own descriptor: it names what the module reads, and javac compiles and checks it. Jenesis takes it as the build. Its requires are the dependencies, two Javadoc tags set the release and the main class, and versions are optional pins in the same file.

The other tools compile the same descriptor, but each dependency must be declared again in their own format - highlighted in their tabs.

  • Jenesis Built in The module descriptor is the build file.
  • Maven Declared twice Every requires again as a <dependency>.
  • Gradle Declared twice Every requires again as implementation(...).
  • Bazel Declared three times Every requires again as a coordinate and as a label. It runs on the class path.
sources/module-info.java14 lines
/** * @jenesis.release 25 * @jenesis.main demo.app.Main * @jenesis.pin org.slf4j 2.0.20 * @jenesis.pin com.fasterxml.jackson.databind 2.22.3 * @jenesis.pin info.picocli 4.7.7 * @jenesis.pin org.jspecify 1.0.1 */module demo.app {    requires org.slf4j;    requires com.fasterxml.jackson.databind;    requires info.picocli;    requires static org.jspecify;}

3 of 14

Tests with JUnit 6

A Jenesis test is a module of its own. It names the module it tests and requires the JUnit API; the engine and the console runner follow from that, so neither is declared. The module under test lets the tests in with a qualified export - exports demo.app to demo.app.test - and no other module can read the package.

  • Jenesis Built in A test module that names the module it tests. The engine is inferred.
  • Maven Built in One test dependency. Surefire runs JUnit 6 unchanged.
  • Gradle Built in useJUnitPlatform() and an explicit launcher dependency.
  • Bazel Community ruleset java_test runs JUnit 4 only. JUnit 6 takes java_junit5_test from contrib_rules_jvm.
tests/module-info.java8 lines
/** * @jenesis.test demo.app * @jenesis.pin org.junit.jupiter.api 6.1.3 */module demo.app.test {    requires demo.app;    requires org.junit.jupiter.api;}
sources/module-info.java16 lines
/** * @jenesis.release 25 * @jenesis.main demo.app.Main * @jenesis.pin org.slf4j 2.0.20 * @jenesis.pin com.fasterxml.jackson.databind 2.22.3 * @jenesis.pin info.picocli 4.7.7 * @jenesis.pin org.jspecify 1.0.1 */module demo.app {    requires org.slf4j;    requires com.fasterxml.jackson.databind;    requires info.picocli;    requires static org.jspecify;    exports demo.app to demo.app.test;}

Worth knowing

  • Tests see what the module under test exports to them, and nothing else.

Why not test package-private code from the same package?

Package-private access is for classes that are written and changed together - the collaborators inside one package. A test that sits in that package and calls into it depends on those internals, and nothing declares the dependency: the build never records that the tests read the implementation. A refactoring that keeps the public behaviour can still break them.

The Java Module System cannot express the arrangement at all. A package belongs to exactly one module, so a test in the same package cannot be a module of its own. It can only be compiled into the module under test with --patch-module, and then what runs is no longer what the module descriptor describes. A qualified export states the dependency instead, in the file that already describes the module.

Maven 4 invents a second descriptor

Maven 4 and its 4.x compiler plugin compile same-package tests into the module under test. What the tests need beyond the module's own descriptor goes into a new file, module-info-patch.maven, in a syntax only Maven reads, with Maven-only keywords such as TEST-MODULE-PATH. The compiler honours it; Surefire does not, so what the test run needs is repeated as JVM arguments in argLine.

src/test/java/module-info-patch.mavena test that also uses java.net.http
patch-module demo.app {    add-modules TEST-MODULE-PATH, java.net.http;    add-reads TEST-MODULE-PATH, java.net.http;}

Testing with the Java Module System

Tests as their own moduleTests inside the module
Jenesis Built in A test module names the module it tests. Not offered A package belongs to one module.
Maven 3.9 Built in A module-info.java in the test sources; Surefire runs it as a named module. Built in Same-package tests run on the module path.
Maven 4 Deprecated Still runs, with a warning to use module-info-patch.maven - which patches the main module instead. Patch file module-info-patch.maven for compiling, argLine for running.
Gradle 9.8 Built in A module-info.java in the test sources. By hand A second copy of the main descriptor and --patch-module arguments, or the GradleX plugin. By default tests run on the class path.
Bazel 9.2 Class path It compiles as a module, but runs unnamed. Class path Nothing is ever put on a module path at run time.

4 of 14

Only what changed runs again

A build runs after every edit - in watch mode, in continuous integration, and in the loop of a coding agent, which changes a file, builds, reads the result and goes again. That loop is only as fast as the build's answer. Jenesis hashes what each step reads and skips a step whose inputs did not change, the test step included.

One line in jenesis.properties keeps every step's result in a cache on the file system, outside target/, so a wiped build folder or an edit that is undone costs nothing. One more runs only the tests that reach a changed class. Below is the project of the section before, with the same edits made in every tool.

  • Jenesis Built in A step with unchanged inputs is skipped. A cache on disk is one line, test selection another.
  • Maven Whole modules Only whole modules. The tests run on every build; Apache's build cache extension restores a module whole or rebuilds it whole, all of its tests included.
  • Gradle Whole test tasks The whole test task: one changed class reruns every test of the module. Unchanged tasks are skipped, and one property adds a local build cache.
  • Bazel Whole test targets Whole test targets: a changed dependency reruns every test in each target that declares it. Actions are cached by their inputs, and a disk cache is one line.
jenesis.properties2 lines
jenesis.project.cachejenesis.test.incremental
Terminal
$ java build/jenesis/Make.javatests >>>>       └─ greets() ✔[COMPLETED] Finished in 3.01 seconds$ java build/jenesis/Make.java    # nothing changed[COMPLETED] Finished in 0.53 seconds$ java build/jenesis/Make.java    # Main.java edited[COMPLETED] Finished in 1.96 seconds$ java build/jenesis/Make.java    # Greeter.java editedtests >>>>       └─ greets() ✔[COMPLETED] Finished in 2.76 seconds$ rm -rf target$ java build/jenesis/Make.java[COMPLETED] Finished in 0.58 seconds

Worth knowing

  • GreeterTest reaches Greeter but not Main. The terminal shows the test each build ran and its last line; -Djenesis.print.tests=true prints the tests.
  • Named without a value, jenesis.project.cache keeps the cache in .jenesis/cache. jenesis.cache.uri=file:///... shares one folder between checkouts, and an https:// address a cache server, such as Jenesis Repository.
  • Test selection reads the compiled classes: a test runs when a class it reaches changed. It cannot see reflection, so continuous integration runs the whole suite.

Why the loop matters

A coding agent works in rounds: it edits, builds, reads what failed and edits again, often many times for one task. Every round waits for the build, so work the build repeats is paid on every round, and a test suite that runs whole after each edit is paid in full each time. A build that answers from what it already knows keeps each round short, and the agent checks more of its work in the same time. The same holds for a developer in watch mode.

Maven keeps nothing outside target/

Maven asks each plugin whether there is work. The compiler plugin checks its sources and skips an unchanged module; Maven's test plugin has no such check, so the tests run on every build. There is no cache beside the build folder, so mvn clean discards everything built so far. Apache's build cache extension adds that cache, but only with the module as its unit: a module is restored whole or rebuilt whole. One changed line in a large module costs that module's full compile and its full test suite, every time.

What ran again

Nothing changedA class no test reachesBuild folder wipedAn edit undone
JenesisNothingCompile, no testNothing: cachedNothing: cached
Maven 3.9*NothingThe whole module, all testsNothing: cachedNothing: cached
Gradle 9.8NothingCompile, all testsNothing: cachedNothing: cached
Bazel 9.2NothingCompile, all testsNothing: cachedNothing: cached

* With Apache's build cache extension, which Maven downloads as a plugin. Without it, the tests run on every build and a wiped build folder rebuilds everything.

5 of 14

Several modules, one build

A library and the application that uses it, built together. In Jenesis the Java module name is how one module of the project refers to another: requires demo.greeter is all the wiring there is, and the sibling is built first and resolved from the build itself. There is no root file.

The other tools know a module by a name of their own - an artifactId, a project path, a label - so each sibling is named twice, and the two names are kept in step by hand. The second one is highlighted in their tabs.

  • Jenesis Built in The module name is the reference. No root file.
  • Maven Named twice requires demo.greeter, and again as the artifactId greeter.
  • Gradle Named twice requires demo.greeter, and again as the project :greeter.
  • Bazel Named twice requires demo.greeter, and again as the label //greeter.
greeter/module-info.java3 lines
module demo.greeter {    exports demo.greeter;}
app/module-info.java8 lines
/** * @jenesis.main demo.app.Main * @jenesis.pin org.slf4j 2.0.20 */module demo.app {    requires demo.greeter;    requires org.slf4j;}

6 of 14

One jar for Java 21 and Java 25

A multi-release jar carries a Java 21 baseline and, for one class, a second implementation that only a Java 25 runtime loads. With Jenesis the additional sources go where they end up in the jar, under META-INF/versions/25/; the build compiles them for 25 in a second pass and marks the manifest.

  • Jenesis Built in Sources under META-INF/versions/25/ are the whole configuration.
  • Maven Built in Built in, but configured: a second compiler execution and the manifest entry.
  • Gradle By hand No built-in support: a source set and the jar layout by hand.
  • Bazel By hand No rule builds one: a genrule unpacks two jars and packs them again.
sources/module-info.java6 lines
/** * @jenesis.release 21 * @jenesis.main demo.app.Main */module demo.app {}
Where the sources go
sources/├── module-info.java├── demo/app/Main.java├── demo/app/Platform.java                        compiled for Java 21└── META-INF/versions/25/demo/app/Platform.java   compiled for Java 25

7 of 14

Kotlin with a compiler plugin

Kotlin sources and the kotlinx.serialization compiler plugin, targeting JVM 25. Jenesis sees the .kt files and runs the Kotlin compiler before javac; the plugin is one @jenesis.plugin line, resolved in the compiler's own dependency group. The compiler and the plugin are pinned here.

  • Jenesis Built in The compiler is inferred; the plugin is one line.
  • Maven Official plugin JetBrains' kotlin-maven-plugin, with the serialization plugin as its dependency.
  • Gradle Official plugin Two JetBrains plugins.
  • Bazel Community ruleset rules_kotlin, with a toolchain of your own for JVM 25.
sources/module-info.java13 lines
/** * @jenesis.release 25 * @jenesis.main demo.app.MainKt * @jenesis.plugin kotlinc maven/org.jetbrains.kotlin/kotlin-serialization-compiler-plugin * @jenesis.pin kotlinc/maven/org.jetbrains.kotlin/kotlin-compiler-embeddable 2.4.20 * @jenesis.pin kotlinc/maven/org.jetbrains.kotlin/kotlin-serialization-compiler-plugin 2.4.20 * @jenesis.pin kotlin.stdlib 2.4.20 * @jenesis.pin kotlinx.serialization.json 1.11.0 */module demo.app {    requires kotlin.stdlib;    requires kotlinx.serialization.json;}

8 of 14

Classes from an Avro schema

A schema compiled into Java as part of the build, next to a hand-written class that uses it. With Jenesis the schema sits in the build's own folder beside the sources, and an empty avro.properties switches the generator on. The schema is an input to the build and is not copied into the jar.

  • Jenesis Built in An empty file switches the generator on.
  • Maven Official plugin Apache Avro's own avro-maven-plugin.
  • Gradle Third-party plugin Apache Avro publishes no Gradle plugin; a community fork fills the gap.
  • Bazel By hand No Avro ruleset works on Bazel 9: a genrule runs avro-tools.
sources/module-info.java8 lines
/** * @jenesis.main demo.app.Main * @jenesis.pin avro/maven/org.apache.avro/avro-tools 1.12.2 * @jenesis.pin org.apache.avro 1.12.2 */module demo.app {    requires org.apache.avro;}
sources/META-INF/build.jenesis/avro.propertiesEmpty.
sources/META-INF/build.jenesis/user.avsc9 lines
{  "namespace": "demo.user",  "type": "record",  "name": "User",  "fields": [    {"name": "name", "type": "string"},    {"name": "score", "type": "int"}  ]}

9 of 14

The same bytes, every time

Two clean checkouts, built a minute apart in different folders, with a different time zone and umask, should produce the same jar. Jenesis writes every archive with a fixed entry order, no Unix permissions and one date on every entry, so the jar, its POM and its SBOM come out byte-identical with nothing to configure.

Checking it is a different matter. None of the four tools records a digest of its output and compares it on every build; the rows below say what each offers instead.

  • Jenesis Built in Identical out of the box: delete target, build again, same digest.
  • Maven Opt-in Not by default: one property makes it identical; an Apache plugin compares a rebuild.
  • Gradle Built in Identical out of the box since Gradle 9. Nothing checks it.
  • Bazel Built in Identical out of the box. Comparing two builds is up to you.
Terminal
$ java build/jenesis/Make.java stage$ find target/stage -type f -exec sha256sum {} + | sort -k 2 | sha256sume4c3ec9fbe45ebfda664de841e667dd517233c2990558333e596c5f76ccdc3cd  -$ rm -rf target$ java build/jenesis/Make.java stage$ find target/stage -type f -exec sha256sum {} + | sort -k 2 | sha256sume4c3ec9fbe45ebfda664de841e667dd517233c2990558333e596c5f76ccdc3cd  -

Worth knowing

  • jenesis.archive.timestamp sets another date, such as the last commit's.

10 of 14

Verify what the build downloads

Three checks on every dependency: a SHA-256 checksum recorded in the project, an OpenPGP signature from a key you trust, and a Sigstore identity where the publisher signs that way. Jenesis has all three built in and does the bookkeeping: java build/jenesis/Make.java pin resolves every dependency and writes its version and checksum into the module descriptor.

What you write is whom you trust - one line per signer, naming a key fingerprint or a repository for a group of artifacts, so it outlives version bumps - and two switches in jenesis.properties that make a missing checksum or signature an error.

  • Jenesis Built in pin writes every checksum; you name whom you trust.
  • Maven Third-party plugin Checksums built in; signatures only through third-party plugins.
  • Gradle By hand Checksums and OpenPGP built in; Sigstore only as hand-written build code.
  • Bazel By hand Checksums built in; OpenPGP and Sigstore only as hand-written genrules.
sources/module-info.java28 lines
/** * @jenesis.release 25 * @jenesis.main demo.app.Main * @jenesis.alias protobuf.specs dev.sigstore/protobuf-specs
11 lines written by pin - a version and SHA-256 checksum for every dependency
 * @jenesis.pin com.fasterxml.jackson.core/jackson-annotations 2.22 SHA-256/21ddb598807d3a51a876704eb979d9296e1c6a6f47ab1826ff88c6d6a127a2d0 * @jenesis.pin com.fasterxml.jackson.core/jackson-core 2.22.3 SHA-256/8a501126a385b25841915d839508f8a66e2a0dbc8a6709d055ef3b3e852b094c * @jenesis.pin com.fasterxml.jackson.core/jackson-databind 2.22.3 SHA-256/556db5439e206114346043f68d200497dc96a0bca62a360a81784092ebd0e0a9 * @jenesis.pin com.fasterxml.jackson.databind 2.22.3 SHA-256/556db5439e206114346043f68d200497dc96a0bca62a360a81784092ebd0e0a9 * @jenesis.pin dev.sigstore/protobuf-specs 0.5.2 SHA-256/e2368fd262a9bec078dee8868fc82682cb648ec21906f8b399e348977f3e3a3e * @jenesis.pin info.picocli 4.7.7 SHA-256/f86e30fffd10d2b13b8caa8d4b237a7ee61f2ffccf5b1941de718b765d235bf8 * @jenesis.pin info.picocli/picocli 4.7.7 SHA-256/f86e30fffd10d2b13b8caa8d4b237a7ee61f2ffccf5b1941de718b765d235bf8 * @jenesis.pin org.jspecify 1.0.1 SHA-256/070d75f261fe4c5b8202508366715f7f2d4660f88c8ef7e6d3575e48c9683b66 * @jenesis.pin org.jspecify/jspecify 1.0.1 SHA-256/070d75f261fe4c5b8202508366715f7f2d4660f88c8ef7e6d3575e48c9683b66 * @jenesis.pin org.slf4j 2.0.20 SHA-256/7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439 * @jenesis.pin org.slf4j/slf4j-api 2.0.20 SHA-256/7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439
 * @jenesis.signature OpenPGP/60200AC4AE761F1614D6C46766D68DAA073BE985 org.slf4j/* * @jenesis.signature OpenPGP/28118C070CB22A0175A2E8D43D12CA2AC19F3181 com.fasterxml.jackson.core/* * @jenesis.signature OpenPGP/AA417737BD805456DB3CBDDE6601E5C08DCCBB96 info.picocli/* * @jenesis.signature OpenPGP/41CD49B4EF5876F9E9F691DABAC30622339994C4 org.jspecify/* * @jenesis.signature Sigstore/github.com/sigstore/protobuf-specs dev.sigstore/* */module demo.app {    requires org.slf4j;    requires com.fasterxml.jackson.databind;    requires info.picocli;    requires static org.jspecify;    requires static protobuf.specs;}
jenesis.properties2 lines
jenesis.dependency.pin=strictjenesis.dependency.signature=strict
Commands
$ java build/jenesis/Make.java pin

Worth knowing

  • pin resolves the whole dependency tree and records each version with its SHA-256. With -Djenesis.dependency.pin=ignore it resolves again and rewrites them.
  • OpenPGP signatures are checked by the system's gpgv, so it must be installed.
  • Sigstore bundles are checked in process, with the JDK's own cryptography.

The three checks, by tool

SHA-256 checksumsOpenPGPSigstore
Jenesis Built in Built in Built in
Maven Built in Third-party Third-party
Gradle Built in Built in By hand
Bazel Built in By hand By hand

Every check was proven by tampering: a changed checksum, a changed jar, a wrong key or a wrong Sigstore identity failed the build in each tool.

Who verifies the verifier?

A verifier downloaded from the repository it is meant to check verifies nothing.

  • Jenesis - the verifier is part of the project: build/jenesis/ is source committed with it, or a submodule pinned to one commit, whose hash git checks against every source file it fetches. It changes only through a diff you review, and nothing is downloaded before the checks run. OpenPGP runs the system's gpgv, which reads only a keyring built from the fingerprints you declared. Sigstore bundles are checked with the JDK's own cryptography, against a trust root written into that source.
  • Maven - pgpverify and Sigmund, with Bouncy Castle and sigstore-java, are resolved from Maven Central, the repository they verify, and loaded before they run.
  • Gradle - Bouncy Castle ships inside the distribution, so nothing is downloaded to verify. The distribution itself is only checked when distributionSha256Sum is set, which gradle wrapper does not do by default.
  • Bazel - Bazelisk checks the Bazel release's signature, and Bazel Central Registry archives are pinned by checksum. The OpenPGP and Sigstore checks above run the host's gpg and a cosign binary pinned in the build.

In all four tools the checksums are recorded from what the first download returned. A signature is what ties them to a publisher, and Jenesis can require one for every pinned dependency.

11 of 14

A bill of materials

A CycloneDX SBOM that says what the release is, who makes it, under which licence, and what it contains: every dependency, transitive ones included, with its checksum and its licence. Jenesis writes one into every jar and stages it beside the jar for publishing, with nothing to switch on.

The project's metadata is declared once, in project.properties, and the same values fill the generated POM. Two clean builds give the same SBOM, byte for byte.

  • Jenesis Built in On by default, inside the jar and beside it. The metadata is one file.
  • Maven CycloneDX plugin The CycloneDX plugin reads the pom. A copyright, supplier or manufacturer has no place in it.
  • Gradle CycloneDX plugin The CycloneDX plugin, with the metadata declared a second time. A new file on every build.
  • Bazel Ruleset from Git Nothing built in. A ruleset pulled from Git writes a thin SBOM.
project.properties17 lines
project=demoartifact=appname=Demo Appdescription=A small command-line application used to compare build tools.url=https://example.org/demo-applicense.apache-2_0.name=Apache-2.0license.apache-2_0.url=https://www.apache.org/licenses/LICENSE-2.0.txtorganization.name=Example Orgorganization.url=https://example.orgmanufacturer.name=Example Orgmanufacturer.url=https://example.orgpublisher=Example Orgcopyright=Copyright 2026 Example Orgdeveloper.jdoe.name=Jane Doedeveloper.jdoe.email=jane.doe@example.orgscm.url=https://github.com/example/demo-appscm.connection=scm:git:https://github.com/example/demo-app.git
jenesis.properties1 line
jenesis.project.version=1.0.0
sources/module-info.java14 lines
/** * @jenesis.release 25 * @jenesis.main demo.app.Main * @jenesis.pin org.slf4j 2.0.20 * @jenesis.pin com.fasterxml.jackson.databind 2.22.3 * @jenesis.pin info.picocli 4.7.7 * @jenesis.pin org.jspecify 1.0.1 */module demo.app {    requires org.slf4j;    requires com.fasterxml.jackson.databind;    requires info.picocli;    requires static org.jspecify;}

The modular build's descriptor, unchanged.

Commands
$ java build/jenesis/Make.java stage

Worth knowing

  • The organisation becomes the supplier, beside the manufacturer, the publisher, the copyright, the licence and the links to the website and the repository.
  • Each dependency is listed once, with its purl, the same SHA-256 pin records, its licence, and whether the application runs with it: requires static marks one it only compiles against as excluded.

12 of 14

Two versions of one library

The application uses jackson-core 2.22.3; a library it depends on needs 2.15.4. With Jenesis the library keeps its jackson-core in a module layer of its own: the application declares nothing and never learns that a second version exists. Both run in one JVM, under their own names, from jars nobody has rewritten. The other tools shade: they copy the library's jackson-core into its jar and rename every class in it.

  • Jenesis Built in A module layer. Nothing is copied or renamed.
  • Maven Official plugin The Apache maven-shade-plugin relocates jackson-core.
  • Gradle Third-party plugin The community Shadow plugin relocates jackson-core.
  • Bazel Third-party ruleset No built-in relocation: bazel_jar_jar, after merging the jars.
library/module-info.java11 lines
/** * @jenesis.pin build.jenesis.launcher 0.5.3 * @jenesis.layer jackson api demo.library.api * @jenesis.layer jackson provider demo.library.impl */module demo.library {    requires build.jenesis.launcher;    requires demo.library.api;    exports demo.library;}
impl/module-info.java9 lines
/** * @jenesis.pin com.fasterxml.jackson.core 2.15.4 */module demo.library.impl {    requires com.fasterxml.jackson.core;    requires demo.library.api;    provides demo.library.api.Jackson with demo.library.impl.JacksonImpl;}
api/module-info.java3 lines
module demo.library.api {    exports demo.library.api;}
app/module-info.java8 lines
/** * @jenesis.main demo.app.Main * @jenesis.pin com.fasterxml.jackson.core 2.22.3 */module demo.app {    requires com.fasterxml.jackson.core;    requires demo.library;}

Worth knowing

  • The layer needs an API module that crosses its boundary and a provider module that holds the private dependency; the library looks the provider up through the Jenesis Launcher.
  • It runs from the launcher jar, the bundle, the container image and the jpackage image, under Execute, and with plain java given -Djlayer.modulepath.jackson.

What shading costs, seen on the real jars

Shading copies a library's classes into another jar and renames them. The jars below were built by the Maven and Gradle examples in this section and then inspected; the Jenesis side is the launcher jar of the layer example.

Licences lose their owner

jackson-core's LICENSE and NOTICE land at the root of the library's META-INF/, where they read as the library's own. If the library has its own, jackson's are dropped with a warning. The Apache transformers delete LICENSE and add an Apache Software Foundation attribution that is wrong for Jackson. In an application jar, one version's NOTICE replaces the other's.

Scanners report the wrong version

The dependency-reduced POM no longer names jackson-core, and the application's SBOM does not list the shaded copy. syft, run on the application's shaded jar, reports jackson-core 2.15.4 only - while the application runs 2.22.3 from the same jar.

Stack traces name classes nobody published

A parse error surfaces as demo.library.shaded.jackson.core.JsonParseException, which the application's catch (JsonProcessingException e) does not catch. Jackson's sources jar has no such classes; Maven needs three settings to publish sources a debugger can map.

Before packaging, it is another program

Relocation happens when the jar is packaged. Until then - a multi-module compile, a test run, the IDE - the library runs on the application's jackson-core 2.22.3, a version it does not ship with.

With a layer, jackson-core 2.15.4 keeps all of its files, LICENSE and NOTICE included, unchanged. syft finds both versions in the launcher jar, the build's SBOM lists both, and a stack trace names the original classes with the version of the module that threw: com.fasterxml.jackson.core@2.15.4/com.fasterxml.jackson.core.base.ParserMinimalBase._reportInvalidEOF.

13 of 14

An application with its own runtime

The application as a native launcher with a Java runtime of its own, so it runs where no JDK is installed. Because the module descriptors say exactly which modules the application reads, jlink can link a runtime of just those: here 62 MB, against 303 MB for the JDK it came from. With Jenesis this is one line in packaging.properties, and the build runs jmod and jpackage itself.

  • Jenesis Built in One line. The runtime is linked from the module graph.
  • Maven Third-party plugin Apache has no jpackage plugin: a community one, with the dependencies copied first.
  • Gradle Third-party plugin No jlink or jpackage task: the Badass JLink plugin.
  • Bazel By hand No rule or ruleset: a genrule calls jpackage, with every module jar listed by hand.
build.jenesis/packaging.properties1 line
jpackage=app-image
sources/module-info.java14 lines
/** * @jenesis.release 25 * @jenesis.main demo.app.Main * @jenesis.pin org.slf4j 2.0.20 * @jenesis.pin com.fasterxml.jackson.databind 2.22.3 * @jenesis.pin info.picocli 4.7.7 * @jenesis.pin org.jspecify 1.0.1 */module demo.app {    requires org.slf4j;    requires com.fasterxml.jackson.databind;    requires info.picocli;    requires static org.jspecify;}

The modular build's descriptor, unchanged.

Commands
$ java build/jenesis/Make.java stage

Worth knowing

  • jlink refuses automatic modules. For a dependency that is one - Avro, say - an empty build.jenesis/modules.properties has jdeps work out what each jar reads and gives it a module descriptor; the Avro image then links and parses schemas like any other.

14 of 14

A container image

The application as an image on eclipse-temurin:25-jre. With Jenesis one line in packaging.properties names the base image, and the build writes a complete build context - a Dockerfile, the jars and the launch arguments. Because the module descriptor names the main module, the image starts it on the module path. docker build or podman build makes the image.

  • Jenesis Built in One line; the build writes the Dockerfile and its context.
  • Maven Official plugin Google's Jib builds the image itself, without a Dockerfile.
  • Gradle Official plugin Google's Jib builds the image itself, without a Dockerfile.
  • Bazel Community ruleset rules_oci and rules_pkg build the image without a Dockerfile.
build.jenesis/packaging.properties1 line
docker=eclipse-temurin:25-jre
sources/module-info.java14 lines
/** * @jenesis.release 25 * @jenesis.main demo.app.Main * @jenesis.pin org.slf4j 2.0.20 * @jenesis.pin com.fasterxml.jackson.databind 2.22.3 * @jenesis.pin info.picocli 4.7.7 * @jenesis.pin org.jspecify 1.0.1 */module demo.app {    requires org.slf4j;    requires com.fasterxml.jackson.databind;    requires info.picocli;    requires static org.jspecify;}

The modular build's descriptor, unchanged.

Commands
$ java build/jenesis/Make.java stage$ docker build -t demo/app target/stage/docker/output/module-sources

Get started

A JDK is all it needs.

A Jenesis build lives with the project: its engine is plain Java source under build/jenesis/, launched by the JDK directly. Installing it is populating that folder.

From the project root, write build/jenesis/ and build.

curl -fsSL https://get.jenesis.build | bash
java build/jenesis/Make.java

Getting started, step by step →

Questions

Does my project have to use the Java Module System?

No. Jenesis reads a pom.xml as the description of what to build, which is the quickest way to try it on a project you already have. Where a project declares no modules, its jars resolve by coordinate and compile on the class path.

What if a convention does not cover my build?

The extension is a small Java function or module describing one step, launched by the JDK like the rest of the build - with types, an IDE and refactoring, the same as your application.

How were the examples on this page made?

Each one is a project that was built and run with its tool, and the files shown are copied from it unchanged. The versions are named before the first example.