Sometimes it happens. You stumble upon something that feels brand new to you — only to find out it’s been around for years. When I joined Moderne and started working with its OpenRewrite framework, this exact thing happened. I discovered the Java Service Provider Interface (SPI), a native mechanism in Java that enables plugin-like extensibility in applications.

If you know the technical me a little bit, it won’t surprise you that I wanted to write about this. The Service Provider Interface (SPI) is not that well known[1], so I figured at the beginning of this year it would be a perfect subject for my next blog. But lo and behold, Baeldung just beat me to it with a new blog on the subject. I discussed this with a few colleagues, and Jasper Bogers pointed out the Baeldung article lacked an explanation of why you should use SPI, and how this mechanism allows for adding implementations at runtime. So I was back in the game 😊.

Nothing will work unless you do

I came up with the following use-case. Imagine a hackathon where participants must implement a Contest interface. This interface will receive additional assignments as the hackathon progresses:

public interface Contest {
  @Assignment(value = 1, description = "Determine the highest value of the two numbers")
  int highestNumber(int a, int b);

  @Assignment(value = 2, description = "Remove duplicate characters from the input. Start from left to right.")
  String removeDuplicates(String s);

  // After x minutes, the game master will unlock the next @Assignment
}

Players earn points by correctly solving the assignments. To submit their solutions, they must package their code as a JAR and upload it to a server. The server loads each JAR and evaluates the implementations. If the result is correct, the player earns a point. At the end, the player with the highest score wins.

What road do I take?

To make above scenario work, we need some mechanism to load a JAR into the server at runtime. This is where the SPI does come into play. Using it is surprisingly simple:

  1. Provide an interface.

  2. Let the implementations create a file located in the META-INF/services folder. Name this file exactly as the fully qualified name of the interface and populate it with the fully qualified name of the implementation.

  3. Call ServiceLoader.load(<interface>.class, classLoader) to load the JAR[2].

That’s it!

You make me believe in the impossible

So how can we implement the hackathon scenario? I picked my favourite backend framework: Ktor. We need just two pages, a landing page where participants can upload their JARs manually and a scoreboard page. The details of Ktor file uploads are beyond this post, but for now, know that once a player uploads a JAR, it’s available via the uploadDir variable. Now for the fun part—we unpack all submitted JARs and call their methods:

val updatedScores = mutableMapOf<String, User>()
uploadDir.listFiles { it.extension == "jar" }?.forEach { jar ->
  val classLoader = URLClassLoader(arrayOf(jar.toURI().toURL()), Contest::class.java.classLoader)
  val providers = ServiceLoader.load(Contest::class.java, classLoader) // Iterable, more implementations of the same interface are possible
  val contest = providers.first() // Every player has exactly one implementation

  val results = mutableMapOf<String, Boolean>()
  updatedScores("highestNumber", contest.highestNumber(3, 7) == 7)
  updatedScores("removeDuplicates", contest.removeDuplicates("banana") == "ban")

  val playerName = jar.name.split("-")[0].capitalize()
  updatedScores[playerName] = User(playerName, results)
}

The updatedScores map is then used to render the scoreboard. And just like that—hooray, the hackathon backend is ready! 🎉

Note above code also allows a player to upload an updated version of the solution JAR. When the server reloads their JAR, the process starts over and the new score replaces the previous one.

It does not matter how strong your gravity is, we were always meant to fly.

There is a catch though. What if a player uploads a JAR with an outdated version of the Contest interface? For example, the game master might have unlocked a new assignment:

public interface Contest {
  @Assignment(value = 1, description = "Determine the highest value of the two numbers")
  int highestNumber(int a, int b);

  @Assignment(value = 2, description = "Remove duplicate characters from the input. Start from left to right.")
  String removeDuplicates(String s);

  @Assignment(value = 3, description = "Count the number of vowels (a, e, i, o, u) in the input string.")
  int countVowels(String input);

  // After x minutes, the game master will unlock the next @Assignment
}

If you were to call the countVowels(<input>) method for a JAR which does not implement it yet, you would get a java.lang.reflect.InvocationTargetException. By providing a try-catch solution we can gracefully work around this issue. Furthermore, duplicating the key in the updatedScores map is not that nice either. So in my actual implementation I used reflection to make this all work:

fun Contest.test(fn: KFunction<*>, vararg args: Any) =
  try { fn.call(*arrayOf(this, *args)) } catch (e: Throwable) { false }

Contest::class.declaredFunctions.forEach {
  updatedScores[it.name] = when (it) {
    Contest::highestNumber -> contest.test(it, 3, 7) == 7
    Contest::removeDuplicates -> contest.test(it, "banana") == "ban"
    Contest::countVowels -> contest.test(it, "vowel") == 2
  }
}

But that’s just smoothing the rough edges. All in all, I was really pleased with how easy and fun this API is to use! You can read the entire implementation for yourself on my Github page.

CU,
Jacob


1. This SPI pattern has been usable since Java 1.3, but it became practical and elegant to use starting with Java 6 with the introduction of the ServiceLoader class.
2. The ServiceLoader class has multiple load methods. To load a JAR at runtime with new implementations, you’ll need to use a custom class loader. Class loading is an advanced topic that’s beyond the scope of this post.
shadow-left