Rediscovering Java ServiceLoader: Beyond Plugins and Into Capabilities

When you think of Java modularity, chances are your first thoughts land on JPMS, or perhaps on Spring’s flexible configuration model. For those who "experienced" like me, thought can reach OSGI specification or other stacks like Vert-X. Yet long before either, Java offered a minimal yet powerful mechanism for loose coupling: ServiceLoader.

In this article, we’ll explore what ServiceLoader is, how it works under the hood, what its limitations are, and how to use it effectively in a modern Java ecosystem. We’ll also look at pragmatic workarounds for its constraints and see how to integrate it cleanly into a Spring Boot application. Finally, we’ll reframe ServiceLoader as something more interesting than a “plugin system”: a capability discovery and negotiation mechanism.

What Is ServiceLoader?

ServiceLoader is a Java API introduced in Java 6 that implements the Service Provider Interface (SPI) pattern. While the core API has remained remarkably stable, Java 9 significantly enhanced it with better integration into the module system (JPMS) and with a streaming-based discovery model.

At a high level, SPI is a design pattern that allows a framework or library to define what it needs, while enabling third parties to decide how to provide it. ServiceLoader is Java’s standard, JDK-supported mechanism for implementing this idea.

A ServiceLoader-based SPI consists of:

  • A service: an interface or abstract class
  • One or more providers: concrete implementations
  • A configuration file: listing provider class names (or a module declaration when using JPMS)

The key idea is decoupling: consumers depend only on the service contract, never on concrete implementations.

Here’s an example

public interface GreetingService {                          (1)
    String greet(String name);
}
public class EnglishGreeting implements GreetingService {   (2)
    public String greet(String name) {
        return "Hello, " + name;
    }
}
1 Service definition
2 Provider

Then, in your META-INF/services/ folder (inside a JAR or resource directory):

META-INF/services/com.example.GreetingService
com.example.EnglishGreeting

You can then call the service from any client code as:

ServiceLoader<GreetingService> loader =
    ServiceLoader.load(GreetingService.class);

for (GreetingService svc : loader) {
    System.out.println(svc.greet("Nicolas"));
}

No annotations. No framework dependencies. No explicit reflection. Just declarative discovery and lazy instantiation.

Despite its age, the ServiceLoader API still powers many critical parts of the JDK itself—including JDBC drivers, scripting engines, and, most notably, the new Java logging infrastructure that has make possible to create a simple framework to customize different logging needs, that make it valuable also for software in ESA ( European Space Agency - Dev’s Manual - search for LoggerFinder).

SPI vs. ServiceLoader

It is useful to be precise about terminology:

  • SPI is the design pattern.
  • ServiceLoader is Java’s standard implementation of that pattern.

You can design an SPI without ServiceLoader, but ServiceLoader provides a portable, well-understood, and officially supported mechanism that works across the entire Java ecosystem.

Abstract Classes Are Supported

A common misconception is that services must be interfaces. In reality, ServiceLoader accepts any public, non-final class—including abstract classes.

public abstract class Plugin {
    public abstract void execute();
}

public class ConsolePlugin extends Plugin {
    public void execute() {
        System.out.println("Running console plugin");
    }
}

As long as the provider is a concrete subtype and exposes a public no-argument constructor, ServiceLoader can instantiate it without issue.

How Does It Work Internally?

Understanding ServiceLoader’s internals explains both its elegance and its constraints.

When you call:

ServiceLoader.load(MyService.class)

The loader performs the following steps:

  1. Searches for resources named META-INF/services/MyService on the classpath or module layer
  2. Parses each line to obtain provider class names
  3. Loads each provider class using the selected class loader
  4. Instantiates providers via a public no-argument constructor
  5. Caches the discovered providers for subsequent iterations

All of this is done lazily. Providers are instantiated only when they are iterated over or explicitly requested. Internally, this behavior is driven by a private LazyIterator.

Importantly, ServiceLoader does not scan the entire classpath. It performs targeted resource lookups, which makes it predictable and relatively inexpensive.

ServiceLoader also integrates cleanly with JPMS (Java 9+). In fully modular applications, service usage and provision can be declared directly in module-info.java, eliminating the need for META-INF/services/…​ files while strengthening encapsulation.

ServiceLoader works with GraalVM native images, but provider discovery is not always automatic. In many cases, providers must be explicitly registered at build time to be included in the native image.

The Provider Interface (Java 9+)

Java 9 introduced ServiceLoader.Provider<S>, a lazily resolved wrapper around a provider implementation.

ServiceLoader<GreetingService> loader =
    ServiceLoader.load(GreetingService.class);

loader.stream()
      .filter(p -> p.type().getSimpleName().contains("English"))
      .map(ServiceLoader.Provider::get)
      .forEach(svc -> System.out.println(svc.greet("you")));

This API allows you to:

  • Inspect implementation classes
  • Read annotations or metadata
  • Apply selection logic

—all without eagerly instantiating providers.

ServiceLoader as Capability Discovery

ServiceLoader is often described as a “plugin system”, but this framing is limiting. A more powerful mental model is capability discovery.

Instead of asking “which plugin should I load?”, ask “what can this runtime do?”.

public interface CompressionProvider {
    String algorithm();
    byte[] compress(byte[] input);
}

Multiple providers may coexist (for example: gzip, brotli, or zstd). At runtime:

CompressionProvider provider =
    ServiceLoader.load(CompressionProvider.class)
        .stream()
        .map(ServiceLoader.Provider::get)
        .filter(p -> p.algorithm().equals("zstd"))
        .findFirst()
        .orElseThrow();

No configuration files. No enums. No if/else chains. Just declarative capability discovery.

Versioning and Negotiation

ServiceLoader itself has no built-in notion of versioning, but SPI design can compensate.

public interface PaymentProvider {
    int apiVersion();
    boolean supports(Currency currency);
}

Selection logic becomes explicit and testable:

ServiceLoader.load(PaymentProvider.class)
    .stream()
    .map(ServiceLoader.Provider::get)
    .filter(p -> p.apiVersion() == 2)
    .filter(p -> p.supports(EUR))
    .findFirst();

This approach works particularly well for long-lived APIs and evolving platforms.

Limitations: Constructors with Arguments

ServiceLoader requires providers to expose a public no-argument constructor. Internally, it relies on reflective instantiation:

clazz.getDeclaredConstructor().newInstance();

There is no native support for constructor arguments or dependency injection. This limitation is deliberate but can be worked around using factories and indirection.

Step 1 – Provide a Default Constructor

public class AdvancedPlugin implements Plugin {
    private final Config config;

    public AdvancedPlugin() {}                   (1)

    public AdvancedPlugin(Config config) {.      (2)
        this.config = config;
    }
}
1 Required default constructor
2 Preferred constructor

Step 2 – Wrap Providers with a Lazy Proxy

ServiceLoader<Plugin> loader = ServiceLoader.load(Plugin.class);

List<Plugin> plugins = loader.stream()
    .map(ServiceLoader.Provider::type)
    .map(cls -> (Plugin) Proxy.newProxyInstance(
        cls.getClassLoader(),
        new Class<?>[] { Plugin.class },
        (proxy, method, args) -> {
            Plugin real = PluginFactory.create(cls);
            return method.invoke(real, args);
        }))
    .toList();

This preserves compatibility with ServiceLoader while allowing controlled instantiation and dependency injection.

Spring Boot Integration

Spring provides its own SPI mechanism (SpringFactoriesLoader), but it does not prevent the use of Java’s standard one.

To expose ServiceLoader providers as Spring beans:

@Configuration
public class SpiConfig {

    @Bean
    public ServiceListFactoryBean greetingServices() {
        var fb = new ServiceListFactoryBean();
        fb.setServiceType(GreetingService.class);
        return fb;
    }
}

Injected as usual:

@Component
public class Startup {

    public Startup(List<GreetingService> services) {
        services.forEach(svc -> System.out.println(svc.greet("Spring")));
    }
}

Beans obtained via ServiceLoader do not automatically participate in Spring’s full lifecycle. One workaround is to register them explicitly:

AbstractAutowireCapableBeanFactory beanFactory =
    (AbstractAutowireCapableBeanFactory)
        applicationContext.getAutowireCapableBeanFactory();

beanFactory.registerSingleton(name, bean);
In Spring Boot fat JARs, META-INF/services/…​ files may be nested inside internal JARs. In such cases, providers may need to be unpacked or otherwise exposed to the class loader.

ServiceLoader as a DI Escape Hatch

A powerful pattern is to combine tools deliberately:

  • Use dependency injection frameworks for application wiring
  • Use ServiceLoader at library or module boundaries

This avoids leaking framework-specific annotations into public APIs and keeps extension points framework-agnostic.

When to Use ServiceLoader

Use ServiceLoader when:

  • You need a standard, dependency-free SPI mechanism
  • You are designing a library or platform extension point
  • You want runtime discovery without external configuration files

Avoid it when:

  • You require constructor injection without indirection
  • You need ordering, lifecycle callbacks, or conditional activation
  • You are deeply embedded in an application-level DI context

Conclusion

ServiceLoader is not flashy—and that is precisely its strength. Its minimalism has kept it stable for more than fifteen years, quietly powering some of the JDK’s most important extensibility points.

Used thoughtfully, it is more than a plugin mechanism. It is a clean, explicit way to model capabilities, negotiate features at runtime, and keep APIs decoupled from implementations.

So dust it off. Rediscover it. And the next time you design an extension point, consider whether you really need annotations, YAML, or XML—or whether ServiceLoader is already enough.

Stefano Fago

Stefano Fago

Informatic passionate about distributed systems, software design, and software architectures experiences as a consultant (in different contexts) and trainer (Java/J2EE), software designer, team leader, and speaker in some Italian meetups; interested in too many topics! Aficionado of the Commodore64 and I'm a bassist/singer as soon as I've free time

Read More
Rediscovering Java ServiceLoader: Beyond Plugins and Into Capabilities
Share this