
Lightweight plugin system with automatic service discovery, builds dependency-injection container graphs, supports encapsulated exposed types, internal bindings, and extensible singular/plural extension points with contribution wiring.
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).
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:
PluginContext).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().
object MyExtensionPoint : ExtensionPoint.Plural<MyExtension>(generic())
@ServiceProvider
object MyPlugin : Plugin {
override val dependencies: Set<Plugin> = emptySet()
override fun PluginSpecificationBuilder.specification() {
// Expose a type to dependent plugins
exposedType<MyService>()
// Declare an extension point that others can contribute to
extensionPoint(MyExtensionPoint)
}
override fun DI.Builder.implementation() {
// Internal bindings, not visible to other plugins
// This provides the implementation for the exposed MyService
bind<MyService> { singleton { MyServiceImpl(instance()) } }
// We can also inject the contributions to our extension point here
bind<SomeInternalStuff> {
singleton { SomeInternalStuffImpl(instance<Set<MyExtension>>()) }
// useless type parameters added for clarity here ^^^
}
}
}Note: In lots of tests of Syrup, the plugins are defined as local
classes andvals. This is because the Kotlin compiler doesn't allow to define local objects. You must use objects in your own code becausesweet-spiexpects you to.
Use assemblePlugins to discover and wire all plugins, then retrieve the DI
container for a given plugin:
fun main() {
val plugins = assemblePlugins {
loadPlugins()
}
// Only exposes its own exposed types
val publicDi = plugins.publicDiFor(MyPlugin)
// Exposes its private implementation, its exposed types and its dependencies' exposed types,
// and the contributions to its owned extension points
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 }
}
}Plugins can define extension points to allow their dependents to contribute functionality.
// Define extension point objects (usually in a shared location near your plugin)
object AnalyticsHandlers : ExtensionPoint.Plural<AnalyticsHandler>(generic())
@ServiceProvider
object AnalyticsPlugin : Plugin {
override fun PluginSpecificationBuilder.specification() {
// Declare ownership of the extension point
extensionPoint(AnalyticsHandlers)
}
override fun DI.Builder.implementation() {
// Inject the contributions into some internal service
bind<AnalyticsService> {
singleton { AnalyticsServiceImpl(instance<Set<AnalyticsHandler>>()) }
// useless type parameters added for clarity here ^^^
// or simply `singleton { new(::AnalyticsServiceImpl) }`
}
}
}
@ServiceProvider
object FirebaseAnalyticsPlugin : Plugin {
override val dependencies = setOf(AnalyticsPlugin)
override fun PluginSpecificationBuilder.specification() {
// Contribute to the extension point defined in CorePlugin
AnalyticsHandlers {
contribution<FirebaseAnalyticsHandler>()
}
}
override fun DI.Builder.implementation() {
bind<FirebaseAnalyticsHandler> { singleton { FirebaseAnalyticsHandler() } }
}
}Then the plugin that defines the extension point can retrieve the contributions through its PluginContext:
// PluginContext is injected inside the plugin's internal DI
class MyService(pluginContext: PluginContext) {
val analyticsHandlers by pluginContext.contributions(AnalyticsHandlers)
// use the analyticsHandlers inside MyService
}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).
Extension points can be singular or plural.
object MySingularExtensionPoint : ExtensionPoint.Singular<MyExtension>()
object MyPluralExtensionPoint : ExtensionPoint.Plural<MyExtension>()Both singular and plural extension points can be defined as optional or not. For example:
extensionPoint(myExtensionPoint, optional = true)myExtensionPoint doesn't have any contributions:
PluginContext.contributionOrNull(myExtensionPoint) returns null
PluginContext.contribution(myExtensionPoint) throws an errormyExtensionPoint doesn't have any contributions:
PluginContext.contributionOrNull(myExtensionPoint) throws an errorPluginContext.contribution(myExtensionPoint) throws an errormyExtensionPoint doesn't have any contributions:
PluginContext.contributions(myExtensionPoint) returns an empty setmyExtensionPoint doesn't have any contributions:
PluginContext.contributions(myExtensionPoint) throws an errorSyrup 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.
Note: Syrup isn't currently released to Maven Central. Please use the
publishToMavenLocaltask 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:
dependencies {
implementation("io.github.ptitjes:syrup-host:0.1.0")
}This project follows a Gradle multi-module layout:
Plugin interface and PluginId.
This is the only dependency your plugin modules need.PluginManager and wires everything together.
Only the application entry point needs this dependency.The shared build logic lives in build-logic.
This project uses Gradle. You can use the Gradle wrapper included in the repository:
# Build and run the sample application
./gradlew run
# Build only
./gradlew build
# Run all checks, including tests
./gradlew check
# Clean all build outputs
./gradlew cleanThis project is licensed under the Apache License 2.0. See LICENSE for details.
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).
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:
PluginContext).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().
object MyExtensionPoint : ExtensionPoint.Plural<MyExtension>(generic())
@ServiceProvider
object MyPlugin : Plugin {
override val dependencies: Set<Plugin> = emptySet()
override fun PluginSpecificationBuilder.specification() {
// Expose a type to dependent plugins
exposedType<MyService>()
// Declare an extension point that others can contribute to
extensionPoint(MyExtensionPoint)
}
override fun DI.Builder.implementation() {
// Internal bindings, not visible to other plugins
// This provides the implementation for the exposed MyService
bind<MyService> { singleton { MyServiceImpl(instance()) } }
// We can also inject the contributions to our extension point here
bind<SomeInternalStuff> {
singleton { SomeInternalStuffImpl(instance<Set<MyExtension>>()) }
// useless type parameters added for clarity here ^^^
}
}
}Note: In lots of tests of Syrup, the plugins are defined as local
classes andvals. This is because the Kotlin compiler doesn't allow to define local objects. You must use objects in your own code becausesweet-spiexpects you to.
Use assemblePlugins to discover and wire all plugins, then retrieve the DI
container for a given plugin:
fun main() {
val plugins = assemblePlugins {
loadPlugins()
}
// Only exposes its own exposed types
val publicDi = plugins.publicDiFor(MyPlugin)
// Exposes its private implementation, its exposed types and its dependencies' exposed types,
// and the contributions to its owned extension points
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 }
}
}Plugins can define extension points to allow their dependents to contribute functionality.
// Define extension point objects (usually in a shared location near your plugin)
object AnalyticsHandlers : ExtensionPoint.Plural<AnalyticsHandler>(generic())
@ServiceProvider
object AnalyticsPlugin : Plugin {
override fun PluginSpecificationBuilder.specification() {
// Declare ownership of the extension point
extensionPoint(AnalyticsHandlers)
}
override fun DI.Builder.implementation() {
// Inject the contributions into some internal service
bind<AnalyticsService> {
singleton { AnalyticsServiceImpl(instance<Set<AnalyticsHandler>>()) }
// useless type parameters added for clarity here ^^^
// or simply `singleton { new(::AnalyticsServiceImpl) }`
}
}
}
@ServiceProvider
object FirebaseAnalyticsPlugin : Plugin {
override val dependencies = setOf(AnalyticsPlugin)
override fun PluginSpecificationBuilder.specification() {
// Contribute to the extension point defined in CorePlugin
AnalyticsHandlers {
contribution<FirebaseAnalyticsHandler>()
}
}
override fun DI.Builder.implementation() {
bind<FirebaseAnalyticsHandler> { singleton { FirebaseAnalyticsHandler() } }
}
}Then the plugin that defines the extension point can retrieve the contributions through its PluginContext:
// PluginContext is injected inside the plugin's internal DI
class MyService(pluginContext: PluginContext) {
val analyticsHandlers by pluginContext.contributions(AnalyticsHandlers)
// use the analyticsHandlers inside MyService
}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).
Extension points can be singular or plural.
object MySingularExtensionPoint : ExtensionPoint.Singular<MyExtension>()
object MyPluralExtensionPoint : ExtensionPoint.Plural<MyExtension>()Both singular and plural extension points can be defined as optional or not. For example:
extensionPoint(myExtensionPoint, optional = true)myExtensionPoint doesn't have any contributions:
PluginContext.contributionOrNull(myExtensionPoint) returns null
PluginContext.contribution(myExtensionPoint) throws an errormyExtensionPoint doesn't have any contributions:
PluginContext.contributionOrNull(myExtensionPoint) throws an errorPluginContext.contribution(myExtensionPoint) throws an errormyExtensionPoint doesn't have any contributions:
PluginContext.contributions(myExtensionPoint) returns an empty setmyExtensionPoint doesn't have any contributions:
PluginContext.contributions(myExtensionPoint) throws an errorSyrup 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.
Note: Syrup isn't currently released to Maven Central. Please use the
publishToMavenLocaltask 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:
dependencies {
implementation("io.github.ptitjes:syrup-host:0.1.0")
}This project follows a Gradle multi-module layout:
Plugin interface and PluginId.
This is the only dependency your plugin modules need.PluginManager and wires everything together.
Only the application entry point needs this dependency.The shared build logic lives in build-logic.
This project uses Gradle. You can use the Gradle wrapper included in the repository:
# Build and run the sample application
./gradlew run
# Build only
./gradlew build
# Run all checks, including tests
./gradlew check
# Clean all build outputs
./gradlew cleanThis project is licensed under the Apache License 2.0. See LICENSE for details.