Syrup

Syrup is a lightweight plugin system for Kotlin Multiplatform.
Plugin objects are discovered automatically at runtime via a ServiceLoader mechanism
(powered by sweet-spi).
They then participate in building a graph of dependency injection (DI) containers
(powered by Kodein).
How it works
Syrup organizes your application as a set of plugins.
Each plugin can define its own exposed types, extension points, extension contributions, and internal bindings.
To ensure modularity and predictable behavior, Syrup follows these encapsulation rules:
- Internal bindings: Any internal bindings are available only to the plugin that defines them.
- Exposed types: Any type exposed by a plugin (via its specification) is available for injection inside its
dependent plugins.
- Extension contributions: When a plugin defines an extension point, contributions from its dependent plugins
are collected and made available back to the plugin that defines the extension point (through its
PluginContext).
Defining a plugin
A plugin is an object that implements the Plugin interface and is annotated with @ServiceProvider so that it can be
discovered at runtime. It defines its contract in specification()and its internal bindings in implementation().
MyExtensionPoint : ExtensionPoint.Plural<MyExtension>(generic())
MyPlugin : Plugin {
dependencies: Set<Plugin> = emptySet()
{
exposedType<MyService>()
extensionPoint(MyExtensionPoint)
}
{
bind<MyService> { singleton { MyServiceImpl(instance()) } }
bind<SomeInternalStuff> {
singleton { SomeInternalStuffImpl(instance<Set<MyExtension>>()) }
}
}
}
Note: In lots of tests of Syrup, the plugins are defined as local classes and vals. This is because the Kotlin
compiler doesn't allow to define local objects. You must use objects in your own code because sweet-spi expects you
to.
Assembling plugins
Use assemblePlugins to discover and wire all plugins, then retrieve the DI
container for a given plugin:
fun main() {
val plugins = assemblePlugins {
loadPlugins()
}
val publicDi = plugins.publicDiFor(MyPlugin)
val privateDi = plugins.privateDiFor(MyPlugin)
val myService by di.instance<MyService>()
myService.doSomething()
}
Inside the assemblePlugins block you can also filter discovered plugins and
contribute extra bindings to every plugin's DI container:
val plugins = assemblePlugins {
loadPlugins()
filterPlugins { it != SomeUnwantedPlugin }
contributePluginBindings { plugin ->
bindSingleton<PluginId> { plugin.id }
}
}
Extension Points
Plugins can define extension points to allow their dependents to contribute functionality.
Then the plugin that defines the extension point can retrieve the contributions through its PluginContext:
class MyService(pluginContext: PluginContext) {
val analyticsHandlers by pluginContext.contributions(AnalyticsHandlers)
}
Note: In lots of tests of Syrup, the extension points are defined as local vals. This is because the Kotlin
compiler doesn't allow to define local objects. You should use objects in your own code (as we do in the sample app)
because it eases the extension point discovery by other plugin authors (via IDE's subtypes search).
Singular vs. plural extension points
Extension points can be singular or plural.
object MySingularExtensionPoint : ExtensionPoint.Singular<MyExtension>()
object MyPluralExtensionPoint : ExtensionPoint.Plural<MyExtension>()
Optional extension points
Both singular and plural extension points can be defined as optional or not. For example:
extensionPoint(myExtensionPoint, optional = true)
Using in your projects
Gradle plugin setup
Syrup relies on sweet-spi and
KSP for service discovery.
Add the following plugins to your module's build.gradle.kts:
import dev.whyoleg.sweetspi.gradle.withSweetSpi
plugins {
id("com.google.devtools.ksp") version "2.3.5"
id("dev.whyoleg.sweetspi") versions "0.1.3"
}
kotlin {
withSweetSpi()
}
The withSweetSpi() call configures KSP to generate the service provider metadata
that Syrup uses to discover your plugins at runtime.
Dependencies
Note: Syrup isn't currently released to Maven Central.
Please use the publishToMavenLocal task for now to add it to your local Maven repository:
./gradlew publishToMavenLocal
Add the appropriate dependency in each module's build.gradle.kts:
-
Modules that define plugins only need the runtime library:
dependencies {
implementation("io.github.ptitjes:syrup-runtime:0.1.0")
}
-
The application entry point (where you call assemblePlugins) needs the
host library, which transitively includes the runtime:
Project structure
This project follows a Gradle multi-module layout:
- syrup-runtime: the runtime library containing the
Plugin interface and PluginId.
This is the only dependency your plugin modules need.
- syrup-host: the host library that provides
PluginManager and wires everything together.
Only the application entry point needs this dependency.
- sample: a sample application demonstrating how to define and use plugins.
The shared build logic lives in build-logic.
Build and run
This project uses Gradle. You can use the Gradle wrapper
included in the repository:
./gradlew run
./gradlew build
./gradlew check
./gradlew clean
License
This project is licensed under the Apache License 2.0. See LICENSE for details.