Extending Spring Boot Apps with Plugins

A guide on how to extend Spring Boot Apps with plugins
Let’s say you’re building a platform you want other developers to extend, perhaps with their code packaged in jars. Or you want to reach as many users as possible and you’re deploying the platform in the cloud.
Say you have some potential customers that are not cloud-ready. You also want to provide it as a distributable zip, Docker image, or something your users will be running on their premises. If you do – read on.
Git: https://github.com/darkospoljaric/plugin-platform
Constraints
- You can’t count on your cloud provided to do the restart of your system, cause your platform might not be running in one at all
- Some of your users probably won’t appreciate that they have to do a manual restart or – god forbid – a rebuild or file operations every time a plugin is installed, updated or deleted.
- You can’t package the plugins with the system cause users will come and install/update/delete plugins when they want, obviously!
- Since you want to be cloud native and have the plugins survive restarts, you have to be able to load the plugins from an external location or get them into the container every time it’s started
If these constraints are what you’re dealing with, we might have just what you need.
How to build a Spring boot app
We’re going to build a Spring boot app, and set up a custom classloader that will scan the folder where plugin jars are stored. Then we’ll enable spring context restart to pick up new plugins as they’re installed – all without a JVM restart.
Let’s start with the heart – the classloader. I will assume that you’re familiar with the classloading basics and need a gentle refresher that:
- Classes are only loaded by the classloader, not stored – VM stores the loaded classes in Metaspace
- Classloader is never invoked directly by your code; it’s always done by the runtime
- Classloaders are wired in a parent-child hierarchy. A child can have one parent, and it’s not quite an app code friendly to have multiple children of one parent
The classloader will have a plugin folder path passed to it. We’ll initialize it right after instantiation, but we’ll keep the method public for invocation after plugin upload.
public class PluginClassloader extends ClassLoader {
private final String pluginsFolder;
private List<JarFile> jars;
public PluginClassloader(String pluginsFolder, ClassLoader parent) {
super(parent);
this.pluginsFolder = pluginsFolder;
init();
}
public void init() {
File[] jarFiles = new File(pluginsFolder).listFiles((dir, name) -> name.endsWith(".jar"));
if (jarFiles == null) {
jars = Collections.emptyList();
return;
}
this.jars = Arrays.stream(jarFiles).map(jarFile -> {
try {
return new JarFile(jarFile);
} catch (IOException e) {
// we've just listed them, they're here
return null;
}
}).collect(Collectors.toList());
}
}
Nothing special here, just browsing through the filesystem and creating a list of jars to use later. Now onto the class loading.
A gentle reminder on how classes are loaded
The default classloading algorithm is called “parent first”. Meaning, if a system class loader, or PluginClassLoader from our example, is asked to load a class, they will both delegate to their parent. And the parent will delegate to it’s parent, all the way up to the bootstrap classloader. Bootstrap classloader doesn’t have a parent and the process of loading a class ends with it.
Bit awkward, right? Why not just load via the first one?
That’s because Java runtime wants to prevent JVM extensions and applications from overriding classes in the JRE such as java.lang.Object
and friends. If the bootstrap or extension or system class loader fails to load a class from a plugin you’re developing – which they certainly will – they will delegate downstream to their child classloader, until a class is found. If the class isn’t found you will be greeted with a ClassNotFoundException
.

There are a couple of methods from the base ClassLoader that we’ll need to override.
findClass
The first and obvious one is findClass
which will iterate through all the plugin jars and see if a class is in any of them. It will fetch a list of URLs and just use the first one to define the class and return it. This is the opportunity to create a bit of logic to avoid nasty ClassCastException
and others (depending on what the app code is doing), but that’s beyond the scope of this blog.
@Override
protected Class<?> findClass(final String name) throws ClassNotFoundException {
String className = name.replace('.', '/').concat(".class");
List<URL> resourceUrl = getResourceUrl(className);
if (resourceUrl.size() > 0) {
URL url = resourceUrl.iterator().next();
byte[] bytes = getBytes(url);
Class<?> clazz = defineClass(name, bytes, 0, bytes.length);
resolveClass(clazz);
return clazz;
} else {
throw new ClassNotFoundException();
}
}
private List<URL> getResourceUrl(String className) {
List<URL> urls = new ArrayList<>();
for (JarFile jar : jars) {
ZipEntry entry = jar.getEntry(className);
if (entry != null) {
urls.add(createUrl(entry.getName(), jar));
}
}
return urls;
}
findResources
@Override
protected Enumeration<URL> findResources(final String name) {
List<URL> urls = getResourceUrl(name);
return Collections.enumeration(urls);
}
This method is being used by Spring to see which jars it has to scan for classes. After it does it’s scanning (note: no classloader is used for scanning), then the classes get loaded, instantiated, wired, and more.
findResource
@Override
protected URL findResource(final String name) {
List<URL> resourceUrls = getResourceUrl(name);
return resourceUrls.isEmpty() ? null : resourceUrls.iterator().next();
}
Possibly a bit unexpected. However, this method is used by Spring to fetch the class resource to read annotation metadata via it’s own ClassReader and other supporting classes. Why this way, and not some other, is beyond the scope of this blog.
Digression
The first classloader that comes to mind when loading an external jar is a UrlClassLoader. This would work if we only had one jar to load or can settle for a tall classloader tree that needs to be rebuilt every time a plugin is created/upgraded/deleted.
Upload plugins
For convenience, there are 2 plugins available for you to use in the sample repository. Each has one controller in it. Upload them with:
curl --location --request POST 'http://localhost:8080/plugins/upload' --form 'file=@"/fullPathTo/plugin-platform/spring-plugin/target/spring-plugin-0.0.1-SNAPSHOT.jar"'
curl --location --request POST 'http://localhost:8080/plugins/upload' --form 'file=@"/fullPathTo/plugin-platform/another-spring-plugin/target/another-spring-plugin-0.0.1-SNAPSHOT.jar"'
This POST will trigger a file copy to the filesystem and an immediate refresh of the PluginClassLoader
.
@PostMapping("/upload")
@ResponseBody
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
File targetFile = new File("plugins" + File.separator + file.getOriginalFilename());
OutputStream fileOutputStream = new FileOutputStream(targetFile);
FileCopyUtils.copy(file.getInputStream(), fileOutputStream);
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (cl.getParent() instanceof PluginClassloader) {
((PluginClassloader)cl.getParent()).init();
}
return "ok";
}
Refresh app
After the plugins are uploaded we need to scan them and, well, plug them in! This is done via one of the spring boot app’s actuator endpoints, namely “restart”. There’s a way to do the restart immediately after upload, by auto wiring the actuator into our own controller. But we’re using a plain actuator for a more controlled experience. After all, an administrator might want to upload all plugins first and then trigger the restart instead of restarting on every upload.
curl --location --request POST 'http://localhost:8080/actuator/restart'
This will immediately trigger app context restart – which will not restart the JVM in which the app is running. Before you can call the endpoint you have to enable it in application.properties
management.endpoints.web.exposure.include=health,info,restart,refreshmanagement.endpoint.restart.enabled=true
curl --location --request GET 'http://localhost:8080/introspect/endpoints'
You should be seeing the endpoints that came with the uploaded plugins in the response.
Wrap up
The only thing remaining if you’re running the container in k8s, or any cloud, is to pay attention to how your liveness probe is defined. Especially if your application is large and the context restart takes a bit longer. Also, if you’re running multiple pods, you’ll have to somehow schedule and/or sync restarts. This shouldn’t be a problem via an event sent to a topic.