Kotlin's Plugin Discovery - Service Provider Interface Explained Kotlin's Plugin Discovery - Service Provider Interface Explained

A practical guide to the JVM Service Provider Interface (SPI) for Kotlin: what it is, real world usage in Ktor and SLF4J, META-INF/services mechanics, and when to use alternatives.

Listen to this article#

AI-generated narration

0:00 / 13:53

Prerequisites and assumptions#

You are working with Kotlin on the JVM or Kotlin Multiplatform. You understand basic interfaces and classloading concepts. You have encountered scenarios where you want pluggable implementations without hardcoding dependencies.

Good reads and references


What you get with SPI#

The Service Provider Interface is a built-in JVM mechanism introduced in Java 6 for discovering and loading implementations at runtime. It’s a registry where libraries announce “I provide an implementation of X” and applications ask “give me all implementations of X” without either side knowing concrete class names at compile time.

Why Kotlin teams use this

  • Pluggable backends like JDBC drivers, Ktor HTTP engines, or SLF4J logging that load without explicit imports
  • Optional feature extensions that activate only when present on the classpath
  • Decoupled architecture where the core depends on contracts and providers depend on contracts but never on each other
  • Library authors use it to allow users to swap implementations (e.g., different serialization formats, HTTP clients)

SPI inverts the dependency. Instead of your application importing concrete provider classes, providers register themselves and your application discovers them through a standard mechanism.


The discovery workflow at a glance#

SPI has two phases. Phase one is registration where providers declare their existence. Phase two is discovery where the application loads implementations at runtime.

Define service interface
Provider implements interface
Create META-INF/services file
ServiceLoader scans resources
Application iterates providers
Lazy instantiation on demand

Steps you perform

  1. Define a service interface or abstract class that represents the contract
  2. Implement the interface in one or more provider classes with public no argument constructors
  3. Register providers via META-INF/services files
  4. Use ServiceLoader.load(Contract::class.java) to discover and iterate implementations

API versus SPI: understanding the difference#

The distinction between API and SPI clarifies when to use this mechanism.

AspectAPISPI
Call directionApplication calls libraryLibrary or framework loads implementations
Who implementsLibrary authorsApplication or third party developers
Binding timeCompile time through importsRuntime through discovery
CouplingDirect dependency on concrete classesDependency only on interface
ExamplesList.add(), Files.readString()JDBC Driver, SLF4J LoggerFactory

An API exposes functionality you consume directly. An SPI exposes extension points you implement. The JVM uses SPI internally for JDBC drivers, charset providers, file system providers and more.


Registration with META-INF/services#

Basic registration#

This is the traditional approach that works in all JVM environments.

  1. Define your service interface:
package com.example.crypto

interface HashEngine {
    fun hash(data: ByteArray): ByteArray
    fun algorithm(): String
}
  1. Implement the interface with a public no-argument constructor:
package com.example.impl

import com.example.crypto.HashEngine
import java.security.MessageDigest

class SHA256Engine : HashEngine {
    override fun hash(data: ByteArray): ByteArray =
        MessageDigest.getInstance("SHA-256").digest(data)
    override fun algorithm() = "SHA-256"
}
  1. Create a provider configuration file in your JAR at:
META-INF/services/com.example.crypto.HashEngine
  1. List fully qualified implementation class names one per line:
com.example.impl.SHA256Engine
  1. Load implementations:
import java.util.ServiceLoader

val engines = ServiceLoader.load(HashEngine::class.java)
for (engine in engines) {
    println("Found: ${engine.algorithm()}")
}

Alternative: Module descriptors#

Java 9+ introduced module descriptors that can declare providers without META-INF/services files. Skip this unless you’re building a JPMS library. META-INF/services works everywhere and doesn’t require module-info.java.


How ServiceLoader discovers and instantiates#

Here’s how ServiceLoader actually works under the hood.

ProviderClassLoaderServiceLoaderAppProviderClassLoaderServiceLoaderApploop[For each provider]load(HashEngine.class)find META-INF/services resourcesresource URLsparse class names (lazy)iterator() or stream()loadClass(providerName)Class objectnewInstance()instanceprovider instance

Key characteristics:

  • Lazy instantiation: Providers are created only when iteration advances, not when load() is called
  • Caching per ServiceLoader instance: A single ServiceLoader instance caches loaded providers; call reload() to clear cache and rescan
  • Error handling: ServiceConfigurationError is thrown if a provider class can’t be loaded or instantiated
  • Classloader scoped: Different classloaders see different provider sets

When to use SPI and when to choose alternatives#

ScenarioUse SPIRationale
Pluggable drivers or backendsStandard pattern, zero coupling
Optional feature modulesLoad only what is present
User installed extensionsDrop in JAR approach
Need explicit ordering or prioritiesRequires additional layer like @Priority annotation
Hot plugin reload at runtimeSPI is classloader snapshot based
Complex plugin lifecycle or dependenciesUse OSGi, PF4J or similar framework
Need constructor parameters or dependency injectionUse factory pattern or combine with DI
Sandboxing or security isolationSPI provides no isolation

Use SPI when you need a simple list of implementations. If you need lifecycle management, ordering, or complex wiring, you’ll want a dedicated plugin framework like OSGi or PF4J instead.


Kotlin specific considerations#

Constructor requirements#

Providers must have a public zero parameter constructor. Gotcha: Kotlin default parameter values don’t generate a Java-visible zero parameter constructor. This trips up a lot of people.

// Does NOT work: default parameters don't create zero-parameter constructor
class MyProvider(val config: Config = Config.default()) : Service // ✗ Fails at runtime

// Works: explicit zero parameter primary constructor
class MyProvider() : Service {
    private val config = Config.default()
}

// Works: explicit secondary zero-parameter constructor
class MyProvider : Service {
    constructor() : this(Config.default())
    constructor(config: Config) // Additional constructors are fine
}

// Does NOT work: required constructor parameters
class MyProvider(val config: Config) : Service // ✗ Fails at runtime

Factory pattern for stateful providers#

If providers need configuration, expose a factory interface instead:

interface ParserFactory {
    fun create(settings: Settings): Parser
}

class JsonParserFactory : ParserFactory {
    override fun create(settings: Settings) = JsonParser(settings)
}

Load factories via SPI and create configured instances explicitly.

Caching with lazy delegation#

import java.util.ServiceLoader

val parsers: List<Parser> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
    ServiceLoader.load(Parser::class.java).toList()
}

This materializes the list once and reuses it. The SYNCHRONIZED mode ensures thread safe initialization.


Complete example#

Let’s put it all together with a working multi-module example.

Service contract (crypto-api/src/main/kotlin/com/example/crypto/HashEngine.kt):

package com.example.crypto

interface HashEngine {
    fun hash(data: ByteArray): ByteArray
    fun algorithm(): String
}

Provider implementation (crypto-impl/src/main/kotlin/com/example/impl/SHA256Engine.kt):

package com.example.impl

import com.example.crypto.HashEngine
import java.security.MessageDigest

class SHA256Engine : HashEngine {
    override fun hash(data: ByteArray): ByteArray =
        MessageDigest.getInstance("SHA-256").digest(data)
    override fun algorithm() = "SHA-256"
}

Provider configuration (crypto-impl/src/main/resources/META-INF/services/com.example.crypto.HashEngine):

com.example.impl.SHA256Engine

Application code (app/src/main/kotlin/com/example/app/Main.kt):

package com.example.app

import com.example.crypto.HashEngine
import java.util.ServiceLoader

fun main() {
    val engines = ServiceLoader.load(HashEngine::class.java).toList()
    
    if (engines.isEmpty()) {
        error("No hash engines found")
    }
    
    for (engine in engines) {
        val data = "Hello SPI".toByteArray()
        val hash = engine.hash(data)
        println("${engine.algorithm()}: ${hash.joinToString("") { "%02x".format(it) }}")
    }
}

Build structure (Gradle multi-module):

// crypto-api/build.gradle.kts
plugins {
    kotlin("jvm")
}

// crypto-impl/build.gradle.kts
plugins {
    kotlin("jvm")
}

dependencies {
    implementation(project(":crypto-api"))
}

// app/build.gradle.kts
plugins {
    kotlin("jvm")
    application
}

dependencies {
    implementation(project(":crypto-api"))
    runtimeOnly(project(":crypto-impl"))
}

application {
    mainClass.set("com.example.app.MainKt")
}

Run with ./gradlew :app:run. The application discovers SHA256Engine at runtime without importing it directly.


Real-world examples: Where you’ll encounter SPI#

Ktor HTTP client engines:

Ktor uses SPI to discover HTTP client engines. When you add ktor-client-okhttp or ktor-client-cio to your dependencies, they register themselves via META-INF/services:

META-INF/services/io.ktor.client.engine.HttpClientEngineFactory

This lets you write:

val client = HttpClient() // Defaults to CIO if present; engines are discoverable via SPI

SLF4J logging backends:

When you add Logback or Log4j2 to your Kotlin project, they register via SPI:

META-INF/services/org.slf4j.spi.SLF4JServiceProvider

Your logging calls automatically route to the discovered implementation:

val logger = LoggerFactory.getLogger(MyClass::class.java)
logger.info("SPI discovered the logging backend")

JDBC drivers:

Database drivers register themselves so you can use them without explicit initialization:

META-INF/services/java.sql.Driver
// Driver auto-discovered via SPI
val connection = DriverManager.getConnection("jdbc:postgresql://localhost/db")

JVM only limitation: Why SPI doesn’t work in KMP common code#

SPI is a JVM only mechanism. It can’t be used in Kotlin Multiplatform common code because:

  • ServiceLoader is in java.util, not available on Native/JS/Wasm targets
  • META-INF/services is a JAR/classpath concept that doesn’t exist on other platforms
  • The reflection-based discovery mechanism relies on JVM classloading

Invalid KMP pattern (this won’t compile):

// commonMain - ERROR: ServiceLoader not available
import java.util.ServiceLoader // ✗ Doesn't exist on Native/JS

expect interface AnalyticsProvider {
    fun track(event: String)
}

fun getProvider() = ServiceLoader.load(AnalyticsProvider::class.java) // ✗ Won't compile

Correct KMP approach using expect/actual:

// commonMain
interface AnalyticsProvider {
    fun track(event: String)
}

expect fun getAnalyticsProvider(): AnalyticsProvider

// jvmMain - SPI discovery here
import java.util.ServiceLoader

actual fun getAnalyticsProvider(): AnalyticsProvider {
    // Warning: Provider order is unspecified. Consider explicit selection.
    return ServiceLoader.load(AnalyticsProvider::class.java).firstOrNull()
        ?: error("No analytics provider found")
}

// META-INF/services/com.example.AnalyticsProvider in jvmMain resources
com.example.analytics.GoogleAnalyticsProvider

// nativeMain - Direct instantiation
actual fun getAnalyticsProvider(): AnalyticsProvider =
    FirebaseAnalyticsProvider()

// jsMain
actual fun getAnalyticsProvider(): AnalyticsProvider =
    BrowserAnalyticsProvider()

Use SPI inside platform-specific source sets (jvmMain), not in commonMain. For truly multiplatform plugin discovery, use dependency injection or manual registration.


Common pitfalls and solutions#

ProblemCauseSolution
No providers foundWrong file path or typo in FQCNVerify META-INF/services/<exact.interface.name>
ServiceConfigurationError at runtimeClass not found or instantiation failsCheck public no-argument constructor and classpath
Default parameters failKotlin defaults don’t create Java zero parameter constructorUse explicit zero parameter constructor
Wrong classloader contextRunning in container with custom loadersUse ServiceLoader.load(Service.class, explicitClassLoader)
Using SPI in KMP commonMainServiceLoader is JVM onlyUse expect/actual pattern (see KMP section)

SPI and dependency injection#

SPI and DI (Koin, Hilt, Kodein) serve different purposes but can complement each other.

AspectSPIDI Container
DiscoveryDeclarative META-INF/services fileClasspath scanning or explicit configuration
LifecycleProvider manages itselfContainer manages scopes and graphs
ConfigurationManual or customAnnotations and conventions
Use casePluggable implementationsApplication wiring

Common patterns in Kotlin:

  • Use SPI to discover available implementations, then let Koin/Hilt manage the chosen one
  • Use DI inside a provider implementation for its internal dependencies
  • Expose a factory via SPI that integrates with your DI framework

Example with Koin:

import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import java.util.ServiceLoader

val analyticsModule = module {
    single<AnalyticsProvider> {
        // Warning: Provider order is unspecified. Do not rely on firstOrNull() 
        // unless you control the classpath or use explicit selection logic.
        ServiceLoader.load(AnalyticsProvider::class.java).firstOrNull()
            ?: error("No analytics provider found")
    }
}

Testing patterns#

ScenarioApproach
Unit test the contractMock implementations directly, no ServiceLoader
Integration test discoveryAdd test provider to test/resources/META-INF/services
Test fallback behaviorProvide synthetic low priority provider
Test error handlingCreate invalid entry in test configuration file

Example test provider registration:

src/test/resources/META-INF/services/com.example.Service
com.example.TestServiceImpl

Security and isolation considerations#

  • SPI provides no sandboxing. Loaded code runs with the same permissions as the application.
  • If you need to vet providers, parse the configuration file yourself and whitelist class names before loading.
  • In multitenant or plugin environments, use separate classloaders per tenant and explicit ServiceLoader.load(service, tenantClassLoader).

Thread safety#

ServiceLoader instances aren’t thread safe. Don’t share them across threads or iterate from multiple threads concurrently. Both the loader and its iterator maintain mutable internal state.

Safe approach:

import java.util.ServiceLoader

val engines = ServiceLoader.load(HashEngine::class.java).toList()

Once you’ve got a list, it’s safe to share across threads (assuming the providers themselves are thread safe).

For lazy initialization in concurrent code:

import java.util.ServiceLoader

private val engines: List<HashEngine> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
    ServiceLoader.load(HashEngine::class.java).toList()
}

Don’t call reload() while another thread is iterating—you’ll get unpredictable results.


Glossary#

Service Provider Interface (SPI)

A design pattern and JVM mechanism where a service defines a contract (interface or abstract class) and providers implement it, with discovery happening at runtime rather than compile time.

ServiceLoader

A JDK utility class in java.util that locates and lazily instantiates service provider implementations from the classpath or module path.

Provider configuration file

A text file located at META-INF/services/<fully.qualified.interface.name> that lists implementation class names, one per line.

ServiceConfigurationError

An unchecked exception thrown when a provider cannot be loaded, instantiated or when the configuration file is malformed.

Lazy instantiation

The behavior where ServiceLoader creates provider instances only when iteration advances, not when load() is called.

Factory SPI pattern

An approach where the discovered service is a factory that creates configured instances rather than being the service implementation itself.

Classloader boundary

The isolation layer where service discovery occurs. Different classloaders maintain separate provider sets even for the same service interface.


Closing note#

Define clear service contracts, register providers via META-INF/services, and let ServiceLoader handle runtime discovery. Keep constructors simple, handle errors explicitly, and cache results when needed. For complex plugin systems with lifecycle management or ordering requirements, you’ll want a dedicated framework instead. SPI works best when it stays focused connecting implementations to contracts at runtime with minimal ceremony.

Categories:JVM