DEV Community

Solon Framework
Solon Framework

Posted on

Solon Plugins Beyond the Fatjar: External Extension (E-Spi) and Hot-Plug (H-Spi)

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 / .yml files are loaded as extension config
  • .jar / .zip files 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"
Enter fullscreen mode Exit fullscreen mode

Prefix the value with ! and Solon creates the directory for you:

# extension directory is demo_ext (! means auto-create)
solon.extend: "!demo_ext"
Enter fullscreen mode Exit fullscreen mode

Step 2 — drop files next to the jar

demo.jar
demo_ext/_db.properties
demo_ext/demo_user.jar
demo_ext/demo_order.jar
Enter fullscreen mode Exit fullscreen mode

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"));
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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 stop event
  • 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)