Making ServiceLoader usable: a provider factory

I keep coming back to java.util.ServiceLoader. I have used it to put a JSON layer behind a contract, so the core code carries no direct dependency on any particular JSON library, and I can swap the implementation without touching callers. The same shape works for JWT handling, where the concrete library might be jose4j or another JOSE implementation, and you can easily find other decoupling use-cases.

I keep coming back to java.util.ServiceLoader. I have used it to put a JSON layer behind a contract, so the core code carries no direct dependency on any particular JSON library, and I can swap the implementation without touching callers. The same shape works for JWT handling, where the concrete library might be jose4j or another JOSE implementation, and you can easily find other decoupling use-cases. The motivation is always the same: the application should depend on a capability, not on a vendor.

A while back, I wrote about exactly that idea in Rediscovering Java ServiceLoader: Beyond Plugins and Into Capabilities, where the argument was to treat ServiceLoader as capability discovery rather than a plugin system. That piece hit the limitation everyone hits, the no-argument constructor, and worked around it with a default constructor plus a dynamic proxy that built the real object through a factory on each call. It works, but it is an indirect bolt-on after the fact, not a design. This post is the part I never pinned down back then: turning that workaround into a small, explicit pattern.

The running example below is a mock payments system with Stripe and PayPal specializations, because it is compact enough to show end-to-end. The JSON and JWT cases cited can be built with the same structure.

The two limits ServiceLoader leaves you

The first is the no-argument constructor. Whatever ServiceLoader instantiates must have a public, parameterless constructor. My StripePaymentService takes an API key, so it cannot be the class ServiceLoader loads (not unless I bolt on some init-after-construction step, which I would rather avoid).

The second is selection, or rather the lack of it. ServiceLoader gives you every implementation it finds, in roughly classpath order. There is no ID, nothing to prioritise on, and no way to ask whether a given one even applies in the current environment. With two backends on the classpath and only one configured, working out which to use is your problem, not the loader’s.

Java 9 did add a static provider() method that ServiceLoader calls in place of the constructor. I tried that route first and dropped it. It builds the object, fine, but it gives me nothing to select on, and selecting was the whole point.

Load a factory, not the service

The idea is to stop loading the service and load a small factory instead.

public interface ServiceProvider<S> {
    String id();                                                            (1)
    default int priority() { return 0; }                                    (2)
    default boolean supports(Configuration configuration) { return true; }  (3)
    S create(Configuration configuration);                                  (4)
}
1 A stable identifier used to select a provider explicitly
2 Higher priority wins when several providers apply
3 A provider bows out when its configuration is absent
4 The service is built here, so S can have any constructor it likes

The provider is the type that goes in META-INF/services, so the provider is the class that needs a no-arg constructor. The service is built inside create(), with whatever constructor it likes. This is also where it gets cleaner than the proxy from my earlier article: because the provider is a real object rather than a stand-in, it can carry the metadata ServiceLoader never gave us. An id. A priority. A supports() check that lets a provider bow out when its configuration is not present. If you have used java.sql.Driver, the shape is familiar: a driver is a factory for connections. This is that, made generic.

The trick, in the real classes

It reads better on the actual pair of classes than in the abstract. The service takes the constructor it needs:

public final class StripePaymentService implements PaymentService {
    private final String apiKey;

    public StripePaymentService(String apiKey) {
        this.apiKey = apiKey;
    }

    @Override
    public Receipt pay(Order order) { /* uses apiKey */ }
}

The provider has the no-arg constructor, and it is the provider that builds the service:

public final class StripeProvider implements PaymentServiceProvider {

    @Override public String id()       { return "stripe"; }
    @Override public int    priority() { return 100; }

    @Override
    public boolean supports(Configuration cfg) {
        return cfg.get("payments.stripe.apiKey").isPresent();               (1)
    }

    @Override
    public PaymentService create(Configuration cfg) {
        return new StripePaymentService(cfg.require("payments.stripe.apiKey")); (2)
    }
}
1 The provider opts out cleanly when no Stripe key is configured
2 A direct new with the real constructor: no reflection, no proxy

And only the provider is registered:

META-INF/services/com.acme.payments.api.PaymentServiceProvider
com.acme.payments.stripe.StripeProvider

ServiceLoader only ever constructs StripeProvider through its no-arg constructor. StripePaymentService and its API key are assembled by hand inside create(). The constructor restriction lands on the factory, where it costs nothing, and never reaches the service. That is really all there is to it, and it is the whole reason the pattern exists.

A note on reflection

This is also where the pattern earns its keep against the proxy version. It does not remove reflection: ServiceLoader still instantiates each provider through its no-arg constructor. But that is one cheap call per provider, once, at load time. Everything after it is ordinary Java. The service is built with a direct new inside create(), calls go straight to it, and there is no dynamic proxy and no Method.invoke on the hot path, which is what the earlier proxy approach could not avoid. It also pays off with GraalVM native image: the only reflective surface is the no-arg provider construction that the tooling already knows how to register, there are no proxies to configure, and the real services are reached through normal constructor calls.

A generic registry, and one compiler error

I wanted one registry that could load providers for any service type, including JSON and JWT, not just payments. The obvious call does not compile:

ServiceLoader.load(ServiceProvider<PaymentService>.class) // does not compile

And it cannot. There is no class literal for a parameterized type; erasure means ServiceProvider<PaymentService>.class simply does not exist, while ServiceLoader.load wants a real Class<P>. The way around it is a marker interface, one line per domain:

public interface PaymentServiceProvider extends ServiceProvider<PaymentService> { }

PaymentServiceProvider.class does exist. It pins the type parameter down and keeps the uses and provides clauses readable. That marker is the one piece of boilerplate the generic version asks for, and I have made my peace with it.

ServiceRegistry<PaymentService> registry =
        ServiceRegistry.load(PaymentServiceProvider.class);

Inside, the registry builds the providers once and sorts them by priority:

List<P> loaded = ServiceLoader.load(providerType).stream()
        .map(ServiceLoader.Provider::get)                                   (1)
        .sorted(Comparator.comparingInt((P p) -> p.priority()).reversed())  (2)
        .collect(Collectors.toUnmodifiableList());                          (3)
1 stream() is lazy, so this is where each provider is actually instantiated
2 Highest priority first
3 Pay the reflection cost once, then hold an immutable list

The line that matters is .map(Provider::get). Do it once, hold the immutable list, and the reflection cost is paid and never paid again. Selection then comes in two shapes: get("paypal", cfg) when you already know which one you want, and getDefault(cfg) for the highest-priority provider that supports(cfg).

Where new behaviour goes

One rule kept the design from drifting over time: the provider contract does not change. Anything new hangs off a decorator instead. The seam is a small interface.

public interface ServiceResolver<S> {
    S get(String id, Configuration configuration);
    S getDefault(Configuration configuration);
    List<String> available();
}

ServiceRegistry implements it, and so does the cache, as a decorator:

public S get(String id, Configuration configuration) {
    return cache.computeIfAbsent(
            keyFunction.apply(id, configuration),
            key -> delegate.get(id, configuration));
}

computeIfAbsent on a ConcurrentHashMap is atomic per key, so even with several threads racing for the same ID, you end up with a single instance. The key is pluggable and defaults to the ID, which keeps the map bounded by the number of providers you have. No growth, no leak. Hand it a key derived from volatile config values, though, and it can grow without limit; that is exactly why it is not the default, and why I left eviction out until something genuinely needs it.

Cross-cutting concerns on the service itself (say, logging or a retry proxy) go through a post-processor you pass at load time, a plain UnaryOperator<S>:

ServiceRegistry.load(PaymentServiceProvider.class, LoggingPaymentService::wrap);

Lifecycle reuses AutoCloseable rather than some bespoke Lifecycle interface. If a service implements it, the cache, which is the thing that actually owns the instances, closes it:

if (service instanceof AutoCloseable closeable) {
    try { closeable.close(); } catch (Exception ignored) { }
}

Anything that does not cache just creates and forgets, so it owns nothing and has nothing to close.

Configuration, and why Properties does not stick around

Configuration is an interface, and the single implementation keeps its data in an immutable Map. Properties turns up only as a parser:

public static Configuration ofClasspath(String resource) {
    Properties parsed = new Properties();
    // load...
    return ofProperties(parsed);   // copy into a Map, then let parsed go out of scope
}

I care about this detail more than it probably deserves. Properties extends Hashtable, so every read takes a lock, and it can drag a chain of defaults behind it. Keep one alive for the whole lifetime of your configuration, and you are holding a heavier, synchronized structure for no good reason. Copy the values into an immutable Map, let the Properties object fall out of scope, and what remains is a compact, lock-free snapshot that is safely published.

What I left out

It would have been easy to add a CacheStrategy interface. A TTL policy. An event bus for "provider registered" hooks, a mutable runtime registry, maybe a little dependency-injection container, while I was at it. I added none of that. A ConcurrentHashMap with a pluggable key handles the cases I actually have, and the day I need TTL, I will swap the map inside the decorator without anything else moving. create() stays a plain factory, and the provider contract stays at four methods. Pulling these things back out once people depend on them is far harder than adding them when the need is real.

I also do not keep the ServiceLoader around. It is read once into the immutable list and then dropped, so the design forgoes the loader’s own provider cache and its reload() on purpose. That is a real trade-off, and I am fine with it: you get a snapshot that is immutable and safe to share across threads, instead of one that rediscovers providers at runtime. If I ever need the dynamic behaviour, the honest way is to build a fresh registry, not to mutate this one in place.

On the module path

Under JPMS (Java Platform Module System), the same providers are discoverable declaratively: uses in the API module, provides …​ with in each implementation. The classpath META-INF/services route keeps working as well, so the two can coexist while part of a build is still migrating to modules.

The shape is not new, and it helps to know the neighbours. Spring tackles the same discovery problem with its own metadata file and SpringFactoriesLoader, mapping a contract to a list of implementations much as META-INF/services does. It also ran into the two limits this post is about and answered them inside the framework: ordering and @Conditional for selection, and, since Spring Framework 6, a SpringFactoriesLoader.ArgumentResolver that resolves constructor arguments at load time, so a factory implementation no longer needs a no-arg constructor. Same problem, solved by extending the loader; here it is solved by putting a factory in front of a loader that was never going to change. The two mechanisms are compared in more detail here.

If the manual META-INF/services entry bothers you, Google’s AutoService generates it from an annotation. And when you actually need isolated class loaders, lifecycle callbacks, or hot reload, a heavier framework like PF4J or OSGi Declarative Services is the right call. This pattern stops short of all that on purpose: standard JDK, no dependencies, and selection plus configuration without a container.

The design in one table

The whole thing fits in a small table. Each row is one decision and what it buys you.

Piece What it solves

Provider factory (ServiceProvider)

Gets past the no-arg constructor limit; the service keeps whatever constructor it needs

Marker interface (PaymentServiceProvider)

Supplies the concrete Class<P> token that erasure otherwise denies you

priority() + supports()

The selection ServiceLoader does not give: pick the right provider for the current environment

Immutable snapshot (read once, drop the loader)

Thread-safe reuse, no cache or reload() state to babysit

Decorator on ServiceResolver (the cache)

Adds caching and lifecycle without touching the provider contract

Post-processor (UnaryOperator<S>)

Cross-cutting concerns (logging, metrics, retry) off to the side, not in the contract

AutoCloseable for lifecycle

Reuses a standard idiom instead of a bespoke Lifecycle interface

Conclusion

None of this is specific to payments. Define a domain interface, add the one-line marker, and the spi layer stays put. That was the thread running through my earlier article, too: you are not wiring a plugin, you are declaring a capability and letting the runtime supply it. ServiceLoader handles discovery and instantiation; the factory in front of it covers what ServiceLoader will not: from choosing the right implementation to closing it when you are done.