DEV Community

Cover image for JExten: Building a Robust Plugin Architecture with Java Modules (JPMS)
Luis Iñesta Gelabert
Luis Iñesta Gelabert

Posted on

JExten: Building a Robust Plugin Architecture with Java Modules (JPMS)

1. Motivation: The Road to Modular Isolation

When building extensible applications in Java, developers often start with a simple question: "How can I let users add functionality without recompiling the core application?" The journey usually begins with the standard java.util.ServiceLoader, which provides a simple mechanism for discovering implementations of an interface.

However, as the application grows, a critical problem emerges: "Classpath Hell."

Imagine you have a host application that uses library-v1. You create a plugin system, and someone writes a "Twitter Plugin" that requires library-v2. If you run everything on the same flat classpath, you get a conflict. Either the host crashes because it gets the wrong version of the library, or the plugin fails. You cannot have two versions of the same library on the classpath without facing the risk of runtime exceptions such as ClassDefNotFoundError or NoSuchMethodError.

This was the driving motivation behind JExten. I needed a way to strictly encapsulate plugins so that each one could define its own dependencies without affecting the host or other plugins.

Enter JPMS (Java Platform Module System)

Java 9 introduced the Module System (JPMS), which provides strong encapsulation and explicit dependency graphs. It allows us to create isolated "layers" of modules.

  • Boot Layer: The JVM and platform modules.
  • Host Layer: The core application and its dependencies.
  • Plugin Layers: Dynamically created layers on top of the host layer.

By leveraging JPMS ModuleLayers, JExten allows Plugin A to rely on Jackson 2.14 while Plugin B relies on Jackson 2.10, and both can coexist peacefully within the same running application.

2. Architecture and Design

JExten is designed to be lightweight and annotation-driven, abstracting away the complexity of raw ModuleLayers while providing powerful features like Dependency Injection (DI) and lifecycle management.

The architecture consists of three main pillars:

The Extension Model

At the core, JExten uses a clean separation between the "contract" (API) and the "implementation".

  1. Extension Point (@ExtensionPoint): An interface defined in the host application (or a shared API module) that defines what functionality can be extended.

    @ExtensionPoint(version = "1.0")
    public interface PaymentGateway {
        void process(double amount);
    }
    
  2. Extension (@Extension): The concrete implementation provided by a plugin.

    @Extension(priority = Priority.HIGH)
    public class StripeGateway implements PaymentGateway {
        // ...
    }
    

Notice that you can make use of the ExtensionManager without the PluginManager. This is useful for testing or when you want to use JExten in a non-plugin environment and all the extensions are already available in the modulepath.

The Manager Split

To separate concerns, the library splits responsibilities into two distinct managers:

  1. PluginManager ("The Physical Layer"):

    • This component handles the raw artifacts (JARs/ZIPs).
    • It verifies integrity using SHA-256 checksums ensuring that plugins haven't been tampered with.
    • It builds the JPMS ModuleLayer graph. It reads the plugin.yaml manifest, resolves dependencies (from a local cache or Maven repo), and constructs the classloading environment.
  2. ExtensionManager ("The Logical Layer"):

    • Once layers are built, this component takes over.
    • It scans the layers for classes annotated with @Extension.
    • It manages the lifecycle of these extensions (Singleton, Session, or Prototype scopes).
    • It handles Dependency Injection.

Dependency Injection (DI)

Since plugins run in isolated layers, standard DI frameworks (like Spring or Guice) can sometimes be "too heavy" or tricky to configure across dynamic module boundaries. JExten includes a built-in, lightweight DI system.

You can simply use @Inject to wire extensions together:

@Extension
public class MyPluginService {
    @Inject
    private PaymentGateway gateway; // Automatically injects the highest priority implementation
}
Enter fullscreen mode Exit fullscreen mode

This works seamlessly across module boundaries. A plugin can inject a service provided by the host, or even a service provided by another plugin (if the module graph allows it).

3. Usage Example

Here is a quick look at how to define an extension point, implement it in a plugin, and use it in your application.

I. Define an Extension Point

Create an interface and annotate it with @ExtensionPoint. This is the contract that plugins will implement.

@ExtensionPoint(version = "1.0")
public interface Greeter {
    void greet(String name);
}
Enter fullscreen mode Exit fullscreen mode

II. Implement an Extension

In your plugin module, implement the interface and annotate it with @Extension.

@Extension
public class FriendlyGreeter implements Greeter {
    @Override
    public void greet(String name) {
        System.out.println("Hello, " + name + "!");
    }
}
Enter fullscreen mode Exit fullscreen mode

III. Discover and Use

In your host application, use the ExtensionManager to discover and invoke extensions.

public class Main {
    public static void main(String[] args) {
        // Initialize the manager
        ExtensionManager manager = ExtensionManager.create(pluginManager);

        // Get all extensions for the Greeter point
        manager.getExtensions(Greeter.class)
               .forEach(greeter -> greeter.greet("World"));
    }
}
Enter fullscreen mode Exit fullscreen mode

IV. Package your Extension(s) as a Plugin

Finally, use the jexten-maven-plugin Maven plugin to check your module-info.java at compile time and package your extension into a ZIP bundle that includes all dependencies and the generated plugin.yaml manifest.

<plugin>
    <groupId>org.myjtools.jexten</groupId>
    <artifactId>jexten-maven-plugin</artifactId>
    <version>1.0.0</version>
    <executions>
        <execution>
            <goals>
                <goal>generate-manifest</goal>
                <goal>assemble-bundle</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <hostModule>com.example.app</hostModule>
    </configuration>
</plugin>
Enter fullscreen mode Exit fullscreen mode

You can then install the generated ZIP bundle to your host application:

  public class Application {
    public static void main(String[] args) throws IOException {
        Path pluginDir = Path.of("plugins");

        // Create plugin manager
        PluginManager pluginManager = new PluginManager(
            "org.myjtools.jexten.example.app",  // Application ID
            Application.class.getClassLoader(),
            pluginDir
        );

        // Install plugin from bundle
        pluginManager.installPluginFromBundle(
            pluginDir.resolve("my-plugin-1.0.0.zip")
        );

        // Create extension manager with plugin support
        ExtensionManager extensionManager = ExtensionManager.create(pluginManager);

         // Get extensions from the plugin
        extensionManager.getExtensions(Greeter.class)
            .forEach(greeter -> greeter.greet("World"));
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Comparison with Other Solutions

Choosing the right plugin framework depends on your specific needs. Here is how JExten compares to established alternatives:

PF4J (Plugin Framework for Java)

PF4J is a mature, lightweight plugin framework that relies on ClassLoader isolation.

  • Isolation: PF4J uses custom ClassLoaders to isolate plugins. JExten uses JPMS ModuleLayers. The latter is the "native" Java way to handle isolation since Java 9, strictly enforcing encapsulation at the JVM level.
  • Modernity: While PF4J is excellent, JExten is designed specifically for the modern modular Java ecosystem (Java 21+), taking advantage of module descriptors (module-info.java) for defining dependencies rather than custom manifests.

OSGi

OSGi is the gold standard for modularity, powering IDEs like Eclipse.

  • Complexity: OSGi is powerful but comes with a steep learning curve and significant boilerplate (Manifest headers, Activators, complex service dynamics). JExten offers a fraction of the complexity ("OSGi Lite") by focusing on the 80% use case: strictly isolated extensions with simple dependency injection, without requiring a full OSGi container.
  • Runtime: OSGi brings a heavy runtime. JExten is a lightweight library that sits on top of the standard JVM features.

Layrry

Layrry is a launcher and API for executing modular Java applications.

  • Scope: Layrry focuses heavily on the configuration and assembly of module layers (often via YAML/TOML) and acts as a runner. JExten focuses on the programming model within those layers.
  • Features: Layrry is great for constructing the layers, but it doesn't provide an opinionated application framework. JExten provides the "glue" code—Extension Points, Dependency Injection, and Lifecycle Management—that you would otherwise have to write yourself when using raw Module Layers or Layrry.
Feature JExten PF4J OSGi Layrry
Isolation JPMS ModuleLayers File/ClassLoader Bundle ClassLoaders JPMS ModuleLayers
Configuration Java Annotations Properties/Manifest Manifest Headers YAML/TOML
Dependency Injection Built-in (@Inject) External (Spring/Guice) Declarative Services None (ServiceLoader)
Learning Curve Low Low High Medium

5. Conclusion

JExten is a lightweight, annotation-driven plugin framework that leverages JPMS ModuleLayers to provide isolation and dependency management. It is designed to be easy to use and understand, with a focus on simplicity and ease of use.

Finally, keep in mind that JExten is still in its early stages, and there is much room for improvement. Feel free to contribute to the project on GitHub and/or engage in a discussion in the issues section. Link to the repository is here .

Top comments (0)