Skip to content

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>.parent is optional: missing or empty means the layer is parented on the boot layer. Several parents are comma-separated.
  • leaf and leaf.mainClass are mandatory. leaf.mainModule is optional for LayerGraphSpec itself, but if given, the module is added to the leaf layer's root modules and the package of the main class is opened to tentackle-common, so that LayerBootstrap can invoke main reflectively even if the application does not export or open that package.
  • Relative directories are resolved against the base directory passed to parse. LayerBootstrap passes 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 (MyPlugin above) 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 different MyPlugin class than the one the leaf sees, and findFirstServiceProvider rejects 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 addReads is 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 returning null (not layer-based), so no existing custom ServiceFinder implementation needs to change.
  • DefaultServiceFinder(ModuleLayer, String) — resolves the layer's single classloader and keeps the layer; findServiceURLs then uses a ModuleSorter scoped to that layer instead of ModuleSorter.INSTANCE.
  • ModuleSorter.forLayer(ModuleLayer) — a cached, per-layer sorter, discovering ModuleHooks via ServiceLoader.load(layer, ModuleHook.class) instead of ServiceLoader.load(ModuleHook.class), which depends on the context classloader at the time ModuleSorter.INSTANCE is created. The public ModuleSorter(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 @ClasspathFirst services (see services.md) are always read via the classloader of tentackle-common. Declarations in modules that exist only within a layer are ignored.
  • ServiceFinder.createNameMap and createNameMapToAll always 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:

  1. resolves each configured layer's dependencies via Aether, the same way at build time as tentackle-jlink-maven-plugin's LayerResolver does, 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 to LayerSpec directly;
  2. calls ModuleLayerBuilder.build(spec, ModuleLayer.boot(), parentLoader) itself, in the same JVM, right before the goal's own logic runs;
  3. installs the leaf layer's classloader as the thread's context classloader for the duration of the goal — the exact same trick LayerBootstrap uses — 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.

  1. 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:artifactId for a single project), so nothing here touches the project's POM at all. The simplest approach is mvn dependency:copy-dependencies run twice against a throwaway version each time, e.g.:

    mvn 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
    
    (or simply copy the two jars out of ~/.m2/repository/... by hand). Put dev-layers/ under .gitignore — it's a local development convenience, not build output.

  2. Write a layers.properties by hand — the same format tentackle-jlink-maven-plugin generates 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.of accepts 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 in dev-layers/plugin-a:

    layer.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.Main
    
    (Parenting the leaf on both conflicting versions, as in layer.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.)

  3. 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:

  4. Main class: org.tentackle.common.LayerBootstrap
  5. Module path: the project's completely ordinary, IDE-derived module path (target/classes plus 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
  6. VM options: -Dtentackle.layers=dev-layers/layers.properties
  7. Working directory: the project's base directory, so that the relative paths inside layers.properties resolve the same way they would against a packaged image's conf/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.