You shipped a Solon service as a single fatjar. Then reality shows up: ops wants to point the datasource at a different host without asking you to rebuild, and the business team wants to take one module offline at 2 a.m. without bouncing the whole process. If your only answer is "repackage and redeploy the whole jar," you feel the friction every time.
Solon has two mechanisms built exactly for this seam: E-Spi (external extension) and H-Spi (hot-plug). They sit at different points on the same spectrum, and picking the wrong one costs you either flexibility or stability. Here is how each works and when to reach for which. Everything below is on v4.0.4.
The shared problem: a fatjar is sealed
A fatjar is convenient to deploy and miserable to amend. Config files, business modules, everything is baked in. E-Spi and H-Spi both crack that seal, but with very different contracts:
- E-Spi lets you place config files and plugin jars outside the fatjar, loaded at startup into the same runtime. Simple, no extra dependency, but changes require a restart.
- H-Spi gives each plugin its own isolated ClassLoader and lets you start and stop modules while the service keeps running. More power, more responsibility.
E-Spi: put config and modules beside the jar
E-Spi (external extension) targets the fatjar deployment case directly. You designate an extension directory; at startup Solon scans it and loads what it finds:
-
.properties/.ymlfiles are loaded as extension config -
.jar/.zipfiles are loaded as plugin packages
Step 1 — declare the extension directory
# extension directory is demo_ext (no error if it doesn't exist)
solon.extend: "demo_ext"
Prefix the value with ! and Solon creates the directory for you:
# extension directory is demo_ext (! means auto-create)
solon.extend: "!demo_ext"
Step 2 — drop files next to the jar
demo.jar
demo_ext/_db.properties
demo_ext/demo_user.jar
demo_ext/demo_order.jar
Now the datasource config lives in _db.properties outside the jar, and two business modules ride along as separate plugin jars. Ops can edit that properties file directly; you never touch the fatjar.
Step 3 (optional) — load programmatically
If you'd rather load extras in code, the kernel exposes it directly:
@SolonMain
public class Application {
public static void main(String[] args) throws Exception {
Solon.start(Application.class, args, app -> {
// load a package file
app.classLoader().addJar(new File("/demo.jar"));
// load a properties file
app.cfg().loadAdd(new File("/demo.yml"));
});
}
}
Under the hood, this is AppClassLoader.addJar(URL | File). That single detail explains E-Spi's whole personality:
- Everything is shared — all plugin packages share one ClassLoader, one AppContext, one config tree
- Split or merged, your call — package externally, or bundle with the main app; loading timing is the same either way
- Updates need a restart — because it all rides one ClassLoader loaded at boot, swapping a jar or editing config only takes effect after restarting the main service
- No extra dependency — the kernel provides E-Spi directly
One packaging note: a plugin jar should either be built as a fatjar itself, or have its dependencies folded into the main app (common dependencies especially belong in the main app's build, with the plugin's own pom marking them optional).
Official example: demo2002-external_ext (under 2.Solon_Advanced in the solon-examples repo).
H-Spi: isolate and hot-swap without a restart
H-Spi (hot-plug) is the heavier tool. You develop one business module as a self-contained plugin package, and the running service can load and unload it live. The defining difference from E-Spi is isolation:
- Each plugin gets its own ClassLoader, AppContext, and config — fully isolated
- Need the main app's global resources? Grab them explicitly via
Solon.app(),Solon.cfg(),Solon.context() - Updating a plugin package does not require restarting the main service
- The main app must pull in the solon-hotplug dependency to manage business plugin packages
The ClassLoader contract
Isolation is the whole point, so the class-visibility rules matter:
-
Parent ClassLoader (put common resources here): children can see and use its classes and resources — but anything a child registers must be unregistered in its
stopevent - Sibling ClassLoaders: cannot use each other's classes or resources. Don't wire explicit type interactions between siblings; talk through the event bus instead, passing data as parent-level entity classes or weakly-typed JSON — treat it like calling a remote API
Write start(), and write stop() honestly
A hot-pluggable plugin implements Plugin. start registers what the module needs; stop must remove every resource it registered, or you leak on unload:
public class Plugin1Impl implements Plugin {
AppContext context;
StaticRepository staticRepository;
@Override
public void start(AppContext context) {
this.context = context;
// add my own config file
context.cfg().loadAdd("demo1011.plugin1.yml");
// scan my own beans
context.beanScan(Plugin1Impl.class);
// add my own static file repository (register the classloader)
staticRepository = new ClassPathStaticRepository(context.getClassLoader(), "plugin1_static");
StaticMappings.add("/html/", staticRepository);
}
@Override
public void stop() throws Throwable {
// remove http handlers (use a prefix to make removal easy)
Solon.app().router().remove("/user");
// remove scheduled jobs (pick a job impl that supports manual removal)
JobManager.getInstance().jobRemove("job1");
// remove event subscriptions
context.beanForeach(bw -> {
if (bw.raw() instanceof EventListener) {
EventBus.unsubscribe(bw.raw());
}
});
// remove the static file repository
StaticMappings.remove(staticRepository);
}
}
That stop method is the tax for hot-plug. Routes, jobs, event subscriptions, static repositories — if start added it, stop removes it. Skip a line and you get ghost routes or leaked listeners after unload.
Template rendering has one more ClassLoader gotcha — the renderer must be pinned to the right ClassLoader:
public class BaseController implements Render {
// account for the classloader where templates live
static final FreemarkerRender viewRender = new FreemarkerRender(BaseController.class.getClassLoader());
@Override
public void render(Object data, Context ctx) throws Throwable {
if (data instanceof Throwable) {
throw (Throwable) data;
}
if (data instanceof ModelAndView) {
viewRender.render(data, ctx);
} else {
ctx.render(data);
}
}
}
For cross-module communication, lean on the event bus with weakly-typed payloads (Map / JSON string); DamiBus pairs well here for decoupling. Official example package: demo1011. Plugin management can be pushed further into a repository or platform via solon-hotplug.
Side by side
| Dimension | E-Spi | H-Spi |
|---|---|---|
| ClassLoader / AppContext / config | shared | isolated (fully) |
| Restart after update? | yes | no (hot update) |
| Extra dependency | none (kernel built-in) | solon-hotplug |
| Focus | simple external extension / config edits | isolation + hot-plug + management |
| Resource removal | nothing special | must manually remove all registered resources in stop
|
| Cross-module comms | direct sharing | event bus / weakly-typed data |
| Underlying mechanism | AppClassLoader.addJar |
isolated ClassLoader + Plugin.start/stop
|
Which one do you actually need
Start with the question "does this have to change without a restart?"
- No, a restart window is fine. Use E-Spi. Externalizing datasource config and shipping business modules as sibling jars covers most "I don't want to rebuild the fatjar" cases, with zero extra dependency and no lifecycle bookkeeping.
-
Yes, the service must stay up while a module comes and goes. Use H-Spi. You get true isolation and live swap, and in return you accept the
stop-cleanup discipline and event-bus-only cross-module contract.
A useful mental model: E-Spi moves files outside the jar; H-Spi moves modules into their own runtime bubbles. One is about deployment convenience, the other about operational isolation. Plenty of teams run both — E-Spi for externalized config, H-Spi for the one or two modules that genuinely need hot swap.
If you're deciding how to structure a Solon service for the long haul, it's worth reading both official pages end to end before you commit — the ClassLoader rules in particular reward a careful first read.
What does your fatjar most need to shed first: config, or whole modules?
Top comments (0)