
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.
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
- Official Java ServiceLoader documentation
- Ktor HTTP client engines - real world SPI usage
- SLF4J service provider - logging backend discovery
- Source code:
java.util.ServiceLoaderin the JDK
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.
Steps you perform
- Define a service interface or abstract class that represents the contract
- Implement the interface in one or more provider classes with public no argument constructors
- Register providers via
META-INF/servicesfiles - 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.
| Aspect | API | SPI |
|---|---|---|
| Call direction | Application calls library | Library or framework loads implementations |
| Who implements | Library authors | Application or third party developers |
| Binding time | Compile time through imports | Runtime through discovery |
| Coupling | Direct dependency on concrete classes | Dependency only on interface |
| Examples | List.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.
- Define your service interface:
package com.example.crypto
interface HashEngine {
fun hash(data: ByteArray): ByteArray
fun algorithm(): String
}
- 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"
}
- Create a provider configuration file in your JAR at:
META-INF/services/com.example.crypto.HashEngine
- List fully qualified implementation class names one per line:
com.example.impl.SHA256Engine
- 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.
Key characteristics:
- Lazy instantiation: Providers are created only when iteration advances, not when
load()is called - Caching per ServiceLoader instance: A single
ServiceLoaderinstance caches loaded providers; callreload()to clear cache and rescan - Error handling:
ServiceConfigurationErroris 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#
| Scenario | Use SPI | Rationale |
|---|---|---|
| Pluggable drivers or backends | ✓ | Standard pattern, zero coupling |
| Optional feature modules | ✓ | Load only what is present |
| User installed extensions | ✓ | Drop in JAR approach |
| Need explicit ordering or priorities | ⚬ | Requires additional layer like @Priority annotation |
| Hot plugin reload at runtime | ✗ | SPI is classloader snapshot based |
| Complex plugin lifecycle or dependencies | ✗ | Use OSGi, PF4J or similar framework |
| Need constructor parameters or dependency injection | ⚬ | Use factory pattern or combine with DI |
| Sandboxing or security isolation | ✗ | SPI 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:
ServiceLoaderis injava.util, not available on Native/JS/Wasm targetsMETA-INF/servicesis 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#
| Problem | Cause | Solution |
|---|---|---|
| No providers found | Wrong file path or typo in FQCN | Verify META-INF/services/<exact.interface.name> |
ServiceConfigurationError at runtime | Class not found or instantiation fails | Check public no-argument constructor and classpath |
| Default parameters fail | Kotlin defaults don’t create Java zero parameter constructor | Use explicit zero parameter constructor |
| Wrong classloader context | Running in container with custom loaders | Use ServiceLoader.load(Service.class, explicitClassLoader) |
| Using SPI in KMP commonMain | ServiceLoader is JVM only | Use expect/actual pattern (see KMP section) |
SPI and dependency injection#
SPI and DI (Koin, Hilt, Kodein) serve different purposes but can complement each other.
| Aspect | SPI | DI Container |
|---|---|---|
| Discovery | Declarative META-INF/services file | Classpath scanning or explicit configuration |
| Lifecycle | Provider manages itself | Container manages scopes and graphs |
| Configuration | Manual or custom | Annotations and conventions |
| Use case | Pluggable implementations | Application 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#
| Scenario | Approach |
|---|---|
| Unit test the contract | Mock implementations directly, no ServiceLoader |
| Integration test discovery | Add test provider to test/resources/META-INF/services |
| Test fallback behavior | Provide synthetic low priority provider |
| Test error handling | Create 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.