BoxLang has always been an extensible language. As of 1.17.0, it is a hierarchically extensible one.
Every module ecosystem you have worked in is flat. You declare a list of dependencies and something outside the language, a package manager or a build tool, resolves and downloads and orders them. The language itself has no opinion about the shape of the graph. It sees a list, not a tree.
BoxLang 1.17.0 changes that. Modules can now contain other modules. Recursively. To any depth. Each nested module gets its own class loader, chained to its parent's, and BoxLang's own ModuleService discovers, registers, activates, and unloads that entire tree itself. No package manager. No build tool. No install ordering. Drop in one artifact and everything it depends on comes with it, already wired up and already isolated.
The Convention
Drop a modules/ folder into your module, and BoxLang treats it exactly like a top-level modulesDirectory.
The practical outcome: one self-contained artifact, a single module folder or a single JAR, that brings every module it depends on along with it, fully isolated from the host application's own modules, working the moment it is dropped in.
The distinction that matters most is the one on the right of that diagram, and it is the thing people get wrong first. libs/ and modules/ are not the same thing.
A JAR in libs/ is a library. It is added to your module's classpath and that is the end of its story. No lifecycle, no settings, no class loader of its own.
A JAR in modules/ is a module. It gets its own lifecycle, its own settings, and its own class loader.
Load Order
Nested modules load from the inside out and unload from the outside in.
Registering or activating a parent cascades down the whole tree first, so by the time your module's onLoad() runs, everything it bundles is already active and usable. You can call into a nested module from your own startup code without defensive checks, because it is guaranteed to be there.
Unloading reverses it, because a child's class loader depends on its parent's. Children come down before the loader they hang from.
The same logic explains a behavior worth internalizing: disabling a module skips every module nested inside it. A child's loader chains to its parent's, so it cannot load without it.
Class Loader Chaining
Isolation here is real, not conventional. The class loader hierarchy mirrors the module hierarchy: a nested module's loader is parented to its parent module's loader, chaining upward until a top-level module, whose loader is parented to the runtime.
Put a shared dependency in the parent's libs/ once and every module nested inside it can use it without redeclaring anything, while remaining invisible to siblings and unrelated modules. Dependency sharing downward, isolation sideways.
One implementation detail worth knowing if you are working from Java: module class loaders create a real isolation boundary, so they pass null as the standard ClassLoader parent and track the real one themselves. Walk the chain with DynamicClassLoader.getDynamicParent(), because the standard getParent() returns null for a module loader.
A Module Can Be a Single JAR
The other half of Inception is that a bare *.jar sitting in a modules/ folder is a module. No ModuleConfig.bx, no box.json, no surrounding folder at all.
Its descriptor is a Java IModuleConfig registered through META-INF/services/ortus.boxlang.runtime.modules.IModuleConfig, and its metadata comes from a @BoxModule annotation.
@BoxModule(
name = "javaHelper",
version = "1.0.0",
author = "Ortus Solutions",
description = "A module shipped as a single JAR"
)
public class JavaHelperModule implements IModuleConfig {
@Override
public void configure( IBoxContext context, ModuleRecord moduleRecord ) {
moduleRecord.settings.put( Key.of( "mode" ), "fast" );
}
}
Without name, a JAR module is named after the JAR file itself, so javaHelper.jar becomes javaHelper. Declaring name lets the module name stand independent of the filename, which matters when your build stamps versions into JAR names.
A JAR that exposes no IModuleConfig is disabled with a warning rather than failing the runtime, so a stray JAR dropped into a modules folder logs a complaint instead of breaking startup.
This is what makes bundled dependencies practical. A pure-Java library your module needs can ship as a first-class module in its own right, without a .bx file anywhere in sight.
A Parent Can Override What It Bundles
Nesting a module does not mean losing control over it. A parent declares a this.modules struct mirroring the boxlang.json shape.
// myModule/ModuleConfig.bx
class {
this.version = "1.0.0"
/**
* Per-child overrides for the modules nested inside this one
*/
this.modules = {
"childModule" : {
enabled : true,
settings : {
timeout : 60,
endpoint : "https://internal.example.com"
}
}
}
}
The Java equivalent, for JAR modules:
@Override
public IStruct modules() {
IStruct childSettings = new Struct();
childSettings.put( Key.of( "timeout" ), 60 );
childSettings.put( Key.of( "endpoint" ), "https://internal.example.com" );
IStruct childOverrides = new Struct();
childOverrides.put( Key.enabled, true );
childOverrides.put( Key.settings, childSettings );
IStruct overrides = new Struct();
overrides.put( Key.of( "childModule" ), childOverrides );
return overrides;
}
Those overrides land in the middle of a three-layer precedence chain.
The global app config is applied last and always wins, so a deployment can override anything, including a parent's decision about its own child. The merge is additive: settings the parent does not mention keep the child's own defaults. enabled follows the same order, so a parent can switch a bundled module off and the global config can switch it right back on.
Walking the Tree
Nested modules live in the same flat registry as everything else, so they stay globally addressable by name and their BIFs, components, and mappings work exactly as they would at the top level. The relationship is recorded on both ModuleRecord objects, so either end can find the other.
| Member | On | Description |
|---|---|---|
nestedModules | parent | Array of names of modules nested directly inside this one |
getNestedModule( Key ) | parent | The record for one direct child, or null |
hasNestedModule( Key ) | parent | Whether a module is a direct child of this one |
parentModule | child | The Key of the module this one is nested inside, or null |
isJarModule() | either | Whether this module is packaged as a single JAR |
// From a parent module's ModuleConfig.bx
var child = moduleRecord.getNestedModule( createObject( "java", "ortus.boxlang.runtime.scopes.Key" ).of( "childModule" ) )
log.info( "Child version: #child.version#" )
To see the hierarchy from outside any one module's own code, from a script, an admin dashboard, or a debug session, the new getModuleTree() BIF returns every top-level module as a struct, and every node carries a children struct of the modules nested inside it, recursively.
tree = getModuleTree()
for ( moduleName in tree ) {
node = tree[ moduleName ]
writeOutput( "#moduleName# (v#node.version#)" )
for ( childName in node.children ) {
writeOutput( " ↳ #childName#" )
}
}
Pass a module name to get the subtree rooted at that module instead of the whole forest:
subtree = getModuleTree( "myModule" )
// subtree.children holds myModule's direct nested modules, each with its own .children
An unregistered module name returns an empty struct rather than throwing. Note that nested modules never appear at the top level of the result, so find them under their parent's children entry. getModuleList() and getModuleInfo() still address every module by name in one flat collection, nested or not. getModuleTree() is the one that shows the shape.
Why This Matters
Module authors no longer have to choose between one giant module that does everything and many small modules the user has to install in the right order. A module can be exactly as granular as makes sense internally while presenting as a single install to the outside world.
Because the class loader isolation is real, nested modules never leak their dependencies into modules that did not ask for them. You can bundle a specific version of a Java library without worrying about what version the host application or some other module happens to be using.
One known limitation to be aware of: shutdown ordering across unrelated modules is not dependency-aware, since unloadAll() visits the registry in unspecified order. Nesting order itself is handled, and a module's children always unload before it does.
Next in This Series
Part 2: boxlang check looks at the new syntax checker, why it never executes the code it validates, and how its JSON output gives an AI agent a way to repair its own hallucinated syntax before anything runs.
Add Your Comment