ServiceLoader is great — until you want a constructor argument

java dev.to

I was building a small library that reads accounting files — GnuCash first, other formats maybe later. It's the textbook case for pluggable implementations: an AccBook interface in the API module, one implementation per format in its own module, and the application picks up whichever one is on the path.

Why not AccBook book = new GnucashAccBook(path) and move on? For one format in one app — do exactly that. But the moment your code says new GnucashAccBook, it depends on that implementation: the GnuCash module has to be on the compile path, its classes exported, its name written in every place that opens a book. Add a second format and each of those places grows an if. The interface stops protecting you once you name the class behind it.

Java already ships the tool for exactly this. No framework, no container, works on the module path: ServiceLoader. So I reached for it. Then I hit a wall I'd hit before and forgotten about.

The naive version

The interface is plain:

public interface AccBook {
    String id();
    List<Account> accounts();
}
Enter fullscreen mode Exit fullscreen mode

Discovery is genuinely easy:

AccBook book = ServiceLoader.load(AccBook.class).findFirst().orElseThrow();
Enter fullscreen mode Exit fullscreen mode

But my GnucashAccBook can't do anything without the file path. And ServiceLoader only knows how to call a no-arg constructor (or a static provider() method). There is nowhere to hand it the Path.

So you reach for one of two workarounds, and both are worse than they look.

Workaround 1 — construct empty, configure after:

AccBook book = ServiceLoader.load(AccBook.class).findFirst().orElseThrow();
((Configurable) book).init(path);   // ...and hope nobody touched `book` before this line
Enter fullscreen mode Exit fullscreen mode

Now the object exists in an invalid state between construction and init. You've swapped a constructor argument for a runtime contract the compiler can't see. (And findFirst() quietly picks one if two implementations are on the path — hold that thought.)

Workaround 2 — put a factory behind ServiceLoader:

public interface AccBookFactory {
    AccBook create(Path path);
}

AccBook book = ServiceLoader.load(AccBookFactory.class)
        .findFirst().orElseThrow()
        .create(path);
Enter fullscreen mode Exit fullscreen mode

This one is actually the right shape. But now every project grows an XFactory interface, the findFirst().orElseThrow() dance, and — if you care that two implementations on the path is a mistake — your own "throw if more than one" check. I wrote that glue three times before I admitted it was a pattern.

Extracting the pattern

So I pulled it into a tiny library, druvu-lib-loader. It is the factory-behind-ServiceLoader pattern, made generic once, with the implementation's arguments passed through a type-keyed map.

The implementation side becomes a factory that receives its arguments:

public class GnucashBookFactory implements ComponentFactory<AccBook> {
    @Override
    public AccBook createComponent(Dependencies deps) {
        return new GnucashAccBook(deps.getDependency(Path.class));
    }
    @Override
    public Class<AccBook> type() {
        // Seasoned Spring developers will recognize the silhouette of an old friend.
        // This one retires right after construction, though — no container moves in.
        return AccBook.class;
    }
}
Enter fullscreen mode Exit fullscreen mode

You register it exactly like any other service — META-INF/services, or in module-info.java:

provides com.druvu.lib.loader.ComponentFactory
    with com.myapp.gnucash.GnucashBookFactory;
Enter fullscreen mode Exit fullscreen mode

And in the API module you give callers one obvious entry point — a static load right on the interface:

public interface AccBook {
    String id();
    List<Account> accounts();

    static AccBook load(Path path) {
        return ComponentLoader.load(AccBook.class, Dependencies.of(Path.class, path));
    }
}
Enter fullscreen mode Exit fullscreen mode

So everything the caller ever sees is this:

AccBook book = AccBook.load(path);
Enter fullscreen mode Exit fullscreen mode

The constructor argument is passed. A single implementation is guaranteed — ComponentLoader.load throws if two factories match the type, instead of silently choosing one. No container, no annotations, no classpath scanning.

And when multiple implementations are the point — a plugin system, an exporter per format — the same pattern flips around:

List<Exporter> exporters = MultiComponentLoader.loadAll(Exporter.class, deps);
Enter fullscreen mode Exit fullscreen mode

Every registered factory gets the same Dependencies, you get all the instances. Same discovery, same argument-passing, opposite cardinality rule.

It's not magic

AccBook.load(path) isn't reflection sorcery and it isn't a new DI framework. Underneath it is still ServiceLoader, still the JPMS provides/uses you would have written anyway. The static load is a plain method you write once. The only thing the library adds is the part the JDK left out: a typed way to pass arguments to the implementation, plus a fail-fast "exactly one" rule.

One honest caveat. The compiler checks what you put into DependenciesDependencies.of(Path.class, path) won't accept a String where a Path belongs. But it can't check that you supplied everything the factory will ask for. Forget the Path and the code still compiles — it fails only when load() runs and getDependency(Path.class) comes up empty. This is a real difference from Spring: Spring checks all its wiring at startup, so a missing bean stops the app from booting. Here there is no startup check — the check happens when you call load(). Call it at startup and you catch the mistake at startup. Call it later, and you catch it later. The good news: the load(...) call and the factory that reads it sit right next to each other, so when it fails, there is one obvious place to look. But the compiler won't catch it for you.

When not to use it

If your implementations have no-arg constructors, you don't need any of this — plain ServiceLoader is the right answer, and the library falls back to it anyway. If you already run inside Spring or Guice, use the container you have; this isn't trying to replace it. It earns its place in one specific spot: libraries and modular components that need pluggable, argument-taking implementations without dragging in a container.

That's the whole idea

It's on Maven Central as com.druvu:druvu-lib-loader — source and docs on GitHub, Apache-2.0, Java 21+. If you've ever hand-written the factory-behind-ServiceLoader glue yourself, I'd genuinely like to know whether this matches how you did it — or where you'd have done it differently. Issues and disagreement welcome.

Source: dev.to

arrow_back Back to Tutorials