Isolated Module Layers — Running Two Versions of the Same Dependency¶
Why¶
JPMS resolves a single, flat Configuration per boot: java -p <modulepath> -m app/Main can never
contain two modules of the same name. If part of an application needs one version of a library and
another part needs a different, incompatible version, there is no command-line way to satisfy both —
one of them has to lose, or the whole dependency has to be shaded/relocated.
java.lang.ModuleLayer is the JDK's own escape hatch: a layer is a separate, independently-resolved
module graph, parented on another layer (usually the boot layer), so a sibling layer can define a
module of the same name without the two ever colliding — as long as nothing needs to read both at
once. Tentackle wraps this in a small, declarative API (LayerSpec, LayerGraphSpec,
ModuleLayerBuilder, LayerBootstrap) so that isolating a dependency is a matter of Maven
configuration rather than hand-written ModuleLayer plumbing. Two plugins build on it, in two
different ways: tentackle-jlink-maven-plugin
resolves and launches a layer graph for a packaged application, in a separate process, via
LayerBootstrap; tentackle-wizard-maven-plugin
builds the same kind of graph in-process, at goal-execution time, for goals that run application
code directly inside the Maven build itself (see "Running In-Process" below).
This is new, additive ground: an application with no <layers> configured behaves exactly as
before — the classes described here are simply never touched.
This is a module-path-only feature. A java.lang.ModuleLayer only exists relative to a real
module graph — there is no classpath equivalent of "two sibling layers," because the classpath has
no notion of layers at all, only one flat, ordered list of jars/classes. An ordinary Tentackle
application happily runs unmodified on the classpath (see services.md),
but the moment it adopts <layers> it commits to running on the module path, launched through
LayerBootstrap, for good.
If an application must stay on the classpath and needs conflicting versions of the same dependency, layers are not an option — consider running it as OSGi bundles instead, where each bundle gets its own classloader (see osgi.md).
The model¶
A layer is a name, a set of parent layer names (empty means "parented on the boot layer"), and the directories its own modules live in:
public record LayerSpec(String name, List<String> parentNames, List<java.nio.file.Path> directories) { }
(java.nio.file.Path is written fully qualified in the sources, because org.tentackle.common.Path
lives in the same package.)
A layer graph is the whole declared set of layers plus which one is the leaf — the layer holding the real application:
public record LayerGraphSpec(List<LayerSpec> layers, String leafLayerName,
String leafMainModule, String leafMainClass) {
public static LayerGraphSpec parse(Properties props, java.nio.file.Path baseDir) { ... }
}
parse reads a flat .properties file rather than XML, JSON or YAML:
# generated by tentackle-jlink-maven-plugin
layer.lib-v1.dir=mp/lib-v1
layer.lib-v2.dir=mp/lib-v2
layer.plugin-a.parent=lib-v1
layer.plugin-a.dir=mp/plugin-a
layer.plugin-b.parent=lib-v2
layer.plugin-b.dir=mp/plugin-b
layer.app.dir=mp-leaf
leaf=app
leaf.mainModule=com.example.app
leaf.mainClass=com.example.app.Main
The rules:
- A layer is declared by its mandatory
layer.<name>.dir, which may list several comma-separated directories (or modular jar files). Duplicate layer names are rejected. layer.<name>.parentis optional: missing or empty means the layer is parented on the boot layer. Several parents are comma-separated.leafandleaf.mainClassare mandatory.leaf.mainModuleis optional forLayerGraphSpecitself, but if given, the module is added to the leaf layer's root modules and the package of the main class is opened totentackle-common, so thatLayerBootstrapcan invokemainreflectively even if the application does not export or open that package.- Relative directories are resolved against the base directory passed to
parse.LayerBootstrappasses the directory given by-Dtentackle.layers.basedir, or the current working directory if that property is not set — never the directory of the properties file. The jpackage goal of the jlink plugin sets the property, because a native launcher starts in an arbitrary working directory. - The values are split via
StringHelper.split, which treats a backslash as an escape character. Use forward slashes in paths, even on Windows.
ModuleLayerBuilder.build(spec, bootLayer, parentLoader) turns this into real ModuleLayers, in an
order where every layer's parents already exist before it is built:
Map<String, ModuleLayer> layers = ModuleLayerBuilder.build(spec, ModuleLayer.boot(), someLoader);
ModuleLayer leaf = layers.get(spec.leafLayerName());
One classloader per layer¶
Every layer is defined with ModuleLayer.defineModulesWithOneLoader — one shared classloader for
all of that layer's modules, not one per module. This keeps the layer, not the module, as the unit
of isolation: two modules placed in the same declared layer are not mutually isolated, only
layers are isolated from their siblings. It also means a layer maps 1:1 onto a single ClassLoader,
which is what lets ServiceFinder stay classloader-based with no breaking interface changes (see below).
With several parents, the layer's classloader is parented on the classloader of the first parent
layer; this only matters for classes that are not defined in any module.
Layers must be explicitly disjoint¶
The JDK's own Configuration.resolve does not reject a layer whose two parents each define a
module of the same name — it silently binds to whichever one is found first. Since that would defeat
the entire point (silently picking one of two coexisting versions is worse than an error),
ModuleLayerBuilder checks this itself: a module name reachable through more than one parent is
fine as long as it is the very same Module instance (a shared ancestor layer), but a hard,
immediate TentackleRuntimeException ("conflicting parents") if two different parents each define
their own, distinct module of that name.
The check covers all ancestors of the parents, not only the parents themselves. Hence, a layer
parented on lib-v1 and lib-v2, or on two plugin layers that are in turn parented on lib-v1 and
lib-v2, is rejected — regardless of what the layer's modules actually requires. A layer may have
several parents only if their ancestries define disjoint sets of modules (apart from shared
ancestors). How the leaf still reaches both versions is shown in "Reaching an isolated layer" below.
Unknown or cyclic parent names are rejected as well.
LayerBootstrap¶
java -p <modulepath> -m module/Main cannot express multiple layers in one invocation, so something
has to build the layer graph before the real application starts. That something is
org.tentackle.common.LayerBootstrap:
public static void main(String[] args) throws Exception {
// reads conf/layers.properties (or -Dtentackle.layers=<path>), builds the layer graph,
// sets the leaf layer's classloader as the thread context classloader, then reflectively
// invokes the real application's main(String[])
}
Setting the context classloader before the handoff is the load-bearing trick: every existing,
layer-unaware ServiceFactory.getServiceFinder() call resolves via
Thread.currentThread().getContextClassLoader() (unless a fixed classloader was set via
ServiceFactory.setFixedClassLoader), so application code running inside the leaf layer
needs no new API to transparently pick up services from within its own layer. For the same reason,
ModuleSorter.INSTANCE, created lazily on first use, discovers the ModuleHooks visible from the
leaf layer. The
tentackle-jlink-maven-plugin generates the config and points the launcher at LayerBootstrap
instead of the real main class whenever <layers> are configured — see
jlinkpackage.md.
Reaching an isolated layer¶
A single Java module cannot requires two modules of the same name, and a layer cannot have two
parents defining them (see above). So if two sibling layers pin different versions of the same
dependency, the leaf cannot be parented on both. Instead, each version-pinned consumer (a small
"plugin" module, in the ServiceFinder sense) gets its own dedicated layer, parented on the specific
sibling it needs, and the leaf reaches it at runtime via the plugin's layer — without being parented
on it:
ModuleLayer pluginLayer = LayerBootstrap.getLayer("plugin-a");
ServiceFinder finder = ServiceFactory.getServiceFinder(pluginLayer, "META-INF/services/");
Class<MyPlugin> providerClass = finder.findFirstServiceProvider(MyPlugin.class);
MyPlugin plugin = providerClass.getDeclaredConstructor().newInstance();
Two prerequisites apply:
- The service interface (
MyPluginabove) must be defined in a layer that both the leaf and the plugin layers share as an ancestor, usually the boot layer (the application's regular module path). Otherwise, the provider would implement a differentMyPluginclass than the one the leaf sees, andfindFirstServiceProviderrejects it. - The plugin module must export the provider's package (at least to the leaf module), since the
provider is instantiated via reflection. Core reflection assumes readability, so no
addReadsis necessary.
LayerBootstrap.getLayer(String)/getLayers() expose the layer graph the bootstrap already built
for exactly this lookup. ServiceFactory.getServiceFinder(ModuleLayer, String) and
DefaultServiceFinder's matching constructor are the other new public entry points on the existing
SPI:
ServiceFinder.getModuleLayer()— a new default method returningnull(not layer-based), so no existing customServiceFinderimplementation needs to change.DefaultServiceFinder(ModuleLayer, String)— resolves the layer's single classloader and keeps the layer;findServiceURLsthen uses aModuleSorterscoped to that layer instead ofModuleSorter.INSTANCE.ModuleSorter.forLayer(ModuleLayer)— a cached, per-layer sorter, discoveringModuleHooks viaServiceLoader.load(layer, ModuleHook.class)instead ofServiceLoader.load(ModuleHook.class), which depends on the context classloader at the timeModuleSorter.INSTANCEis created. The publicModuleSorter(ModuleLayer)constructor creates an uncached sorter.
ServiceFactory.getServiceFinder(ModuleLayer, String) always creates a DefaultServiceFinder. A
custom (@Service-replaced) ServiceFinder implementation is never used for module layers.
Layer-bound finders are cached separately from the classloader-based ones, even if both share the
same classloader.
Some aspects are not layer-aware:
- The
@ClasspathFirstservices (see services.md) are always read via the classloader oftentackle-common. Declarations in modules that exist only within a layer are ignored. ServiceFinder.createNameMapandcreateNameMapToAllalways look up the mapped services via the context classloader's finder, even when invoked on a layer-bound finder.
Worked example¶
Two versions of some-lib (module some.lib), each consumed by its own small plugin module. The
plugins implement the service interface com.example.plugin.api.MyPlugin, whose module lives on the
application's regular module path, i.e., in the boot layer:
layer.lib-v1.dir=mp/lib-v1
layer.lib-v2.dir=mp/lib-v2
layer.plugin-a.parent=lib-v1
layer.plugin-a.dir=mp/plugin-a
layer.plugin-b.parent=lib-v2
layer.plugin-b.dir=mp/plugin-b
layer.app.dir=mp-leaf
leaf=app
leaf.mainModule=com.example.app
leaf.mainClass=com.example.app.Main
The app layer is parented on the boot layer only. com.example.app never itself requires some.lib
— it looks up plugin-a's and plugin-b's service providers via their respective ModuleLayers
(LayerBootstrap.getLayer("plugin-a") and "plugin-b"), each of which independently reads its own
version of some.lib from its own parent. Neither sibling ever sees the other's version.
Parenting app on both plugin layers (layer.app.parent=plugin-a,plugin-b) would not work:
both versions of some.lib would be reachable through the two parents, and ModuleLayerBuilder
rejects that as "conflicting parents".
Running In-Process — the Wizard Plugin¶
LayerBootstrap is the right shape for a packaged application: something has to build the layer
graph before java -p <modulepath> -m module/Main even starts, and a separate bootstrap process is
the only way to do that ahead of the real main class. But not every consumer of <layers> launches a
separate process at all. tentackle-wizard-maven-plugin's browse and script goals run application
code — Pdo.createSession(...), then real domain/PDO/persistence classes — directly inside the Maven
build's own JVM, and its other goals load the model the same way LayerBootstrap does, driven by
the thread context classloader (TCCL). For these, building the layer graph and handing off to a
separate LayerBootstrap process would be pure overhead: the mojo already is the process the leaf
layer needs to run in.
So instead of writing a layers.properties file and launching LayerBootstrap, the wizard plugin:
- resolves each configured layer's dependencies via Aether, the same way at build time as
tentackle-jlink-maven-plugin'sLayerResolverdoes, but skips the step of copying them into a distributable image's module-path directories.ModuleFinder.of(Path...)accepts individual modular jar files just as well as directories, so the resolved local-repository jars are handed toLayerSpecdirectly; - calls
ModuleLayerBuilder.build(spec, ModuleLayer.boot(), parentLoader)itself, in the same JVM, right before the goal's own logic runs; - installs the leaf layer's classloader as the thread's context classloader for the duration of the
goal — the exact same trick
LayerBootstrapuses — and restores the previous one afterward.
No LayerBootstrap, no layers.properties file, and no separate process are involved; the "leaf
layer" here is simply whichever classloader the goal's own logic runs under while the context
classloader is swapped. See the wizard plugin's own
Isolated Module Layers
section for its <layers>/<leafLayer> configuration.
Running From an IDE — Without the Maven Plugin¶
tentackle-jlink-maven-plugin is only needed for the last step: turning a finished application
into a jlink/jpackage image. Everything this document describes above — LayerSpec,
LayerGraphSpec.parse, ModuleLayerBuilder, LayerBootstrap — is plain java.base API operating
on a .properties file and some directories of jars; none of it depends on the plugin. So the
first step, getting two conflicting versions of the same dependency running side by side at all,
can and should happen directly in an IDE, before a plugin, an image, or even a POM <layers> config
enters the picture.
-
Get both versions onto disk as plain directories of jars. A layer's dependencies are resolved independently of the enclosing project's own dependency graph (that's the whole point — Maven only ever mediates one version per
groupId:artifactIdfor a single project), so nothing here touches the project's POM at all. The simplest approach ismvn dependency:copy-dependenciesrun twice against a throwaway version each time, e.g.:(or simply copy the two jars out ofmvn dependency:copy-dependencies -Dmdep.outputDirectory=dev-layers/lib-v1 \ -Dartifact=org.example:some-lib:1.0 mvn dependency:copy-dependencies -Dmdep.outputDirectory=dev-layers/lib-v2 \ -Dartifact=org.example:some-lib:2.0~/.m2/repository/...by hand). Putdev-layers/under.gitignore— it's a local development convenience, not build output. -
Write a
layers.propertiesby hand — the same formattentackle-jlink-maven-plugingenerates later, just typed once instead of derived from a<layers>config. Point the leaf layer's directory directly at the IDE's own compiled-output folder (target/classes, or whatever the IDE calls it), never a copy:ModuleFinder.ofaccepts the top-level directory of an exploded module directly, so re-running the app after an IDE recompile (or a hot-swap) picks up the change with no extra step. In the following example, the application uses version 2 directly, while version 1 is only consumed by the plugin module indev-layers/plugin-a:(Parenting the leaf on both conflicting versions, as inlayer.lib-v1.dir=dev-layers/lib-v1 layer.plugin-a.parent=lib-v1 layer.plugin-a.dir=dev-layers/plugin-a layer.lib-v2.dir=dev-layers/lib-v2 layer.app.parent=lib-v2 layer.app.dir=target/classes leaf=app leaf.mainModule=com.example.app leaf.mainClass=com.example.app.Mainlayer.app.parent=lib-v1,lib-v2, is rejected — see "Layers must be explicitly disjoint" above. That's an error at this stage already, same as it would be in a packaged image.) -
Create the run/debug configuration by hand — IDEs derive their auto-generated "Application" configuration from the POM's own main class, which is unaware of any of this, so this one has to be added explicitly, once, and then reused:
- Main class:
org.tentackle.common.LayerBootstrap - Module path: the project's completely ordinary, IDE-derived module path (
target/classesplus the project's regular Maven dependencies) — untouched by any of this, since the conflicting versions were never added to the project's own dependency graph in step 1 - VM options:
-Dtentackle.layers=dev-layers/layers.properties - Working directory: the project's base directory, so that the relative paths inside
layers.propertiesresolve the same way they would against a packaged image'sconf/layers.properties
Debugging needs nothing beyond that: LayerBootstrap and the real application share one JVM, so
breakpoints, stepping, and hot-swap all work normally across every layer's classes, exactly as
they already do with the framework's other multi-classloader mechanisms (OSGi bundles).
Once this works, moving it into the build is a mechanical step, not a redesign: replace the two
copy-dependencies invocations and the hand-written layers.properties with the plugin's own
<layers> config plus its layers goal (bound to process-classes), which resolves the same
dependencies via Aether and (re)generates the same file into target/tentackle-layers/ on every
mvn compile — see jlinkpackage.md.
Building an actual jlink or jpackage image only comes after that, as the final step for producing a
real, distributable artifact — it is never needed just to develop or debug against isolated layers.
Related Documentation¶
- Service and Configuration API —
ServiceFinder/ServiceFactory/ModuleSorter, which this document extends. - Tentackle Modules — the module system this API builds on.
- jlinkpackage.md — the
<layers>/<layer>configuration, packaging layout, and thelayersgoal for local development. - tentackle-wizard-maven-plugin.md — the in-process consumer, running application code inside the Maven build itself.