Structr runs on the Java module path (JPMS). Structr's own code is compiled into explicit JPMS modules; third-party libraries that are JPMS-hostile are quarantined onto the class path. This document explains how that split is produced, how to add dependencies, and how to add new modules.
A built distribution (zip / deb / docker) launches with:
java --module-path lib -cp 'lib-classpath/*:plugins/*:structr-<version>.jar' \
--add-modules ALL-MODULE-PATH -m structr.base/org.structr.Server
| Path | Class/module path | Contents |
|---|---|---|
lib/ |
module path | Structr's own JPMS modules + all modular third-party jars. |
lib-classpath/ |
class path | JPMS-hostile "island" jars (see below), reached only via ServiceLoader SPIs from the quarantined modules. |
plugins/ |
class path | User / optional drop-in jars (e.g. a JDBC driver). Put extra jars here — never in lib/. |
structr-*.jar |
class path | The resources-only application jar (web UI + config templates). |
Never drop arbitrary jars into
lib/: it is resolved with--add-modules ALL-MODULE-PATH, so a jar with split packages / an invalid automatic-module name / an unsatisfiable hardrequireswill abort startup. Useplugins/.
maven-dependency-plugin:copy-dependencies first copies all runtime dependencies into a flat
target/lib. Then a maven-antrun-plugin execution (partition-module-path, package phase) runs a
shared tool that moves the JPMS-hostile jars to target/lib-classpath:
java structr-app/src/main/resources/build/ModulePathPartitioner.java <lib> <lib-classpath> <seed>
Classification is curated seed + automated detection:
- Seed —
structr-app/src/main/resources/build/island-seed.txt. One rule per line:<glob>— force the jar to the class path (e.g.neo4j-*.jar)name:<module>— force the jar with this derived module name to the class path (version-agnostic; e.g.name:org.neo4j.annotations)+<glob>/+name:<module>— pin to the module path (wins over everything; e.g.+neo4j-java-driver-*.jar, kept on the module path for the Bolt driver)
- Automated pass — for every jar still on the module path, the tool quarantines it if it has an
(1) invalid/underivable automatic-module name, (2) duplicate module name, (3) split package shared
with another module-path jar, or (4) an unsatisfiable hard (non-
static)requires. It only ever adds to the class path and never moves a+-pinned jar. Rule (4) runs as a fixpoint (before and after rules 2/3): quarantining a module cascades to every module-path jar that requires it, so a new dependency can't silently leave a requirer orphaned. If a+-pinned jar is itself left with an unsatisfiablerequires(which JPMS could not resolve at boot), the build fails loudly so you fix the seed rather than shipping a broken module path.
The tool + seed are shipped inside structr-app.jar and reused by the enterprise build
(structr-app-enterprise harvests them when it unpacks structr-app). The seed encodes the shared
(OSS) knowledge; the automated pass handles whatever extra dependencies an edition adds, so editions do
not need to hand-curate their own glob lists.
- Add it to the relevant module's
pom.xmlas usual and build. - Most jars need no action — modern, well-formed jars resolve cleanly on the module path.
- If the jar is JPMS-hostile, the automated pass quarantines it for you — no config needed. Verify
after a build:
and boot the feature that uses it.
java --module-path structr-app/target/lib --validate-modules # must print nothing - Only edit the seed for cases the automated rules cannot detect — chiefly a jar that is healthy on
its own but must travel with an already-quarantined engine (the Neo4j
server-api/org.neo4j.annotationscompanions are the canonical example: needed on the class path so the embedded engine does not split across the module/class-path boundary). Add a<glob>orname:<module>line, or a+line to pin something the rules would otherwise move.
To see why a jar was moved, read the partitioner's build output ([partitioner] … (reason)).
structr-app/src/main/resources/build/SpiPreflight.java runs in the package phase right after the
partitioner (OSS and enterprise). It replays java.util.ServiceLoader for the eagerly-scanned SPI
categories (javax.imageio.spi.*, java.sql.Driver, java.nio.charset.spi.CharsetProvider) against the
partitioned module path and fails the build if any provider cannot be instantiated.
Why: some jars register a META-INF/services provider whose constructor works on the class path but
throws when the jar is an automatic module on the module path. The archetype is the legacy Sun
javax.media:jai_imageio (GeoTools transitive): its com.sun.media.imageioimpl.* ImageIO SPIs read the
vendor from the jar manifest, which is null for an automatic module → every SPI constructor throws
IllegalArgumentException("vendorName == null!"). Because ImageIO scans all providers eagerly, one bad
provider aborts the whole scan and breaks unrelated features (this is what broke the barcode() function's
ImageIO.write(..., "PNG", ...)). The failure is invisible at build time and only bites at runtime — the
preflight surfaces it early.
On failure it names the provider and points at the fix, which is almost always: quarantine the offending
jar to the class-path island by adding its glob to island-seed.txt (that is exactly what the
jai_imageio-*.jar entry does). The tool must run compiled with the partitioned module path (a
source-launch against the large module graph hangs), so the build javacs it first. Skip with
-DskipSpiPreflight=true.
To hunt more broadly by hand (all SPI categories, or the "provider class missing at runtime" case), run a
ServiceLoader sweep from an unnamed-module probe launched with
--module-path target/lib --add-modules ALL-MODULE-PATH -cp 'target/lib-classpath/*' and catch per-provider
ServiceConfigurationError.
- Explicit module (preferred): put
module-info.javainsrc/main/module/(its own source root — keeps the compiler's QDox descriptor parser away from the rest of the sources).requires structr.base(+ its dependency modules),exports/opensonly what is needed, andprovidesitsStructrModule/Service/AgentSPI implementations. - Automatic module (fallback): if the module pulls a JPMS-hostile dependency that breaks module
resolution, omit
module-info.javaand keep theMETA-INF/services/...provider files. The module then sits on the module path as an automatic module and reads its hostile deps from the class-path islands (thefile-access/messaging-enginemodules work this way). - Structr discovers all modules/services/agents/drivers via
ServiceLoader(there is no class-path scan), so the provider files /providesclauses are mandatory.
structr.base reflectively instantiates impls that live in feature modules (servlets, websocket
commands, Services, Agents). The base-side call site adds a runtime read edge
(X.class.getModule().addReads(target.getModule())) and the providing module must exports the
package. When you add such a type in a module, export its package.
Java 25 (GraalVM). First build needs network once (some transitive deps use open version ranges that
cannot resolve offline until cached); afterwards mvn -o … works.
Tests are compiled and run on the class path (useModulePath=false), while main code is compiled on
the module path. This is required for two reasons:
- The
structr-basetest-jar derives the same module name (structr.base) as the main jar, so on the module path it is dropped and downstream modules' tests cannot see the shared test base classes (org.structr.test.rest.common.StructrRestTestBase, etc.). - A module-path test fork resolves each modular test module's full JPMS graph, which trips over
JPMS-hostile third-party descriptors. For example
commons-configuration2ships a real (multi-release)module-infowith a hardrequires org.apache.commons.logging, so a module-path fork of a module that (transitively) uses it fails at boot-layer init withFindException: Module org.apache.commons.logging not foundunless a provider of that module happens to be on the path. Running on the class path never resolves those module graphs, so it sidesteps the whole class of problem.
It is configured in three places (no command-line flags needed):
- compile: the
maven-compiler-plugindefault-testCompileexecution in the rootpom.xml(<useModulePath>false</useModulePath>); - run (all modules):
maven-surefire-pluginandmaven-failsafe-pluginare pinned in the rootpom.xml<pluginManagement>with<useModulePath>false</useModulePath>. This makes every module run tests on the class path deterministically, independent of the local Maven version — an unpinned surefire otherwise defaults to a module-path fork for modular modules (which is exactly how thecommons-configuration2FindExceptionabove surfaces on some machines but not others). - run (per-profile extras): the surefire (in-memory profile) + failsafe (neo4j profile) config in
structr-base/pom.xmland thestructr-modulesparent add argLine / fork / systemPropertyVariables settings on top of the pinned defaults.
Note: -DskipTests skips test execution but still compiles tests, so the compile-side setting
matters even for CI jobs that skip test execution.
Structr's "Option A" migration keeps JPMS-hostile third-party jars usable by treating the non-modular ones as automatic modules on the module path. Two categories of build warning follow from that and are expected — they are not defects:
requires (transitive) directive for an automatic module(javac lint, inmodule-info.java):structr.basere-exports several of these shared automatic modules viarequires transitiveso the downstream Structr modules inherit them instead of each re-declaring them. We cannot add module descriptors to third-party jars and the re-export is intentional, so therequires-automaticandrequires-transitive-automaticlint keys are silenced in the rootpom.xmlcompiler config (all other lint, incl.deprecation, stays on).Required filename-based automodules detected: [...]. Please don't publish this project to a public artifact repository!(amaven-compiler-pluginbanner, not javac): printed whenever a compiled modulerequiresa filename-based automatic module (a jar with neithermodule-infonor anAutomatic-Module-Namemanifest entry). It has no plugin off-switch and is all-or-nothing (it keeps firing until every required dependency is a proper/named module). Removing it would mean modularizing ~25 third-party jars (e.g. viamoditectadd-module-info) or upgrading each to a modular release — disproportionate to an informational banner that only matters when consuming Structr's artifacts as JPMS library modules (not the usage model). It is therefore accepted as-is.