Winter is a small and fast dependency injection library for Kotlin on the JVM and Android.

  • Dependencies are declared in immutable components with a plain Kotlin DSL. No annotation processing, no code generation and instances are created by your own factory functions.

  • Components nest: a subcomponent describes a part of the application with a shorter lifetime, e.g. a user session or an Android activity. Its subgraphs see everything from their parent graph but not the other way around.

  • Components can be composed from parts of different modules or targets: include, merge, derive and override them.

  • Optional modules for property injection, Android lifecycles and JUnit 4/5 tests.

All code samples in this guide are compiled and run as tests of the project, see the repository. The API documentation is available here.

Installation

dependencies {
    // Core
    implementation("io.jentz.winter:winter:0.10.0-alpha1")
    // Property delegates for classes that can't use constructor injection
    implementation("io.jentz.winter:winter-delegate:0.10.0-alpha1")
    // AndroidX support (includes winter-delegate)
    implementation("io.jentz.winter:winter-androidx:0.10.0-alpha1")
    // JUnit 4 and JUnit 5 support
    testImplementation("io.jentz.winter:winter-junit4:0.10.0-alpha1")
    testImplementation("io.jentz.winter:winter-junit5:0.10.0-alpha1")
}

Snapshot versions (ending with -SNAPSHOT) are published to the Maven Central snapshot repository:

repositories {
    maven("https://central.sonatype.com/repository/maven-snapshots/")
}

Winter requires Java 17 and Kotlin 1.9 or newer, the AndroidX module requires minSdk 24.

Module Description

winter

Components, graphs and the Winter application object.

winter-delegate

Property delegates and graph resolvers for classes that can’t use constructor injection.

winter-androidx

Activity and view model subgraphs, lifecycle integration.

winter-junit4

JUnit 4 rule to extend the dependency graphs in tests.

winter-junit5

JUnit 5 extensions to extend the dependency graphs and inject test parameters.

Quickstart

Given some classes:

interface Pump
class RotaryPump : Pump
class Heater
class CoffeeMaker(val heater: Heater, val pump: Pump)

Declare how to create them in a component, create a graph from it and retrieve an instance:

val coffeeComponent = component {
    singleton { Heater() }
    singleton<Pump> { RotaryPump() }
    prototype { CoffeeMaker(instance(), instance()) }
}

val graph = coffeeComponent.createGraph()
val coffeeMaker: CoffeeMaker = graph.instance()

Concepts

Term Definition

Component

The immutable registry that describes how to create dependencies. It may contain subcomponents.

Graph

Created from a component. It creates and holds the instances and is used to retrieve them. A graph lives until it is closed.

Subcomponent / Subgraph

A component nested in another component, identified by a qualifier. A subgraph is created from a subcomponent and has the graph of the parent component as parent. It can access all dependencies of its ancestors, but not the other way around.

Scope

The lifetime of an instance in a graph, e.g. one instance per graph (singleton) or one per request (prototype).

Type key

Identifies a dependency by its type and an optional qualifier.

Declaring dependencies

Scopes

component {
    // a new instance for every request
    prototype { CoffeeMaker(instance(), instance()) }
    // created on the first request, then the same instance for the lifetime of the graph
    singleton { Heater() }
    // a singleton that is created as soon as the graph is opened
    singleton<Pump> {
        RotaryPump()
    }.eager()
    // an existing value
    constant("Espresso")
}

Registering the same type twice in a component is an error, unless it happens in an override block (see Composing components).

Constructor references

singletonOf and prototypeOf take a constructor reference and resolve all its arguments from the graph. alias registers the same service under another type, here the interface Pump.

component {
    singletonOf(::Heater)
    singletonOf(::RotaryPump).alias(Pump::class)
    prototypeOf(::CoffeeMaker)
}
singletonOf, prototypeOf and the retrieval helpers provider, lazyInstance and new are extension functions in the package io.jentz.winter.dsl.

Qualifiers

Use a qualifier to register multiple dependencies of the same type. A qualifier is created from a string, an enum value or a class.

val graph = graph {
    constant("Arabica", erased(qualifier("bean")))
    constant("Robusta", erased(qualifier(Roast.Dark)))
    constant("Ethiopia", erased(qualifier<Grinder>()))
}

val bean: String = graph.instance(erased(qualifier("bean")))

Lifecycle callbacks

Services can have callbacks that are invoked after an instance and all its dependencies were created and, for singletons, when the graph is closed. This is the place to release resources.

val graph = graph {
    singleton { Connection() }
        .onPostConstruct {
            // called after the instance and all its dependencies were created
        }
        .onClose { it.close() }
}

val connection: Connection = graph.instance()
graph.close() // calls Connection.close()

Retrieving dependencies

// an instance, the type is inferred from the declaration or given explicitly
val heater: Heater = graph.instance()
val pump = graph.instance<Pump>()

// an optional instance, null if Grinder isn't registered
val grinder: Grinder? = graph.instance()

// a function that resolves the instance on every call
val heaterProvider: () -> Heater = graph.provider()

// resolved on first access
val lazyHeater: Lazy<Heater> = graph.lazyInstance()

// call a constructor with arguments from the graph
val coffeeMaker = graph.new(::CoffeeMaker)

Inside of factories the same methods are available, the factory block has the graph as receiver.

Retrieving a type that isn’t registered throws an EntryNotFoundException, unless it is requested as optional (nullable) type. An exception thrown by a factory is wrapped in a DependencyResolutionException and dependencies that (indirectly) depend on themselves cause a CyclicDependencyException with the chain of dependencies in its message.

Runtime arguments

All retrieval methods take an optional builder block to provide values that are only known at runtime. They are only visible to the factory of the requested dependency, not to the factories of its dependencies. For singletons the block is only used when the instance is created.

val graph = graph {
    singleton { Heater() }
    singleton<Pump> { RotaryPump() }
    prototype { CoffeeMaker(instance(), instance()) }
    // the drink (String) is provided by the caller
    prototype { Order(instance(), instance()) }
}

val order: Order = graph.instance { constant("Flat White") }

Generics

By default type keys are erased: List<Pump> and List<Heater> are both just List. Use generic() keys to keep the type arguments. They are more expensive to create, so erased keys are the default.

val graph = graph {
    singleton(generic<List<Pump>>()) { listOf(RotaryPump(), RotaryPump()) }
    singleton(generic<List<Heater>>()) { listOf(Heater()) }
}

val pumps: List<Pump> = graph.instance(generic())

Subcomponents and subgraphs

Subcomponents partition the dependencies by lifetime. A subgraph is created from a subcomponent with createSubgraph and can be extended with a builder block.

val Session = qualifier("session")
val appComponent = component {
    singleton { HttpClient() }

    subcomponent(Session) {
        singleton { LoginController(instance(), instance()) }
    }
}

val appGraph = appComponent.createGraph()

// A subgraph can be extended with a builder block, e.g. to add runtime values.
val sessionGraph = appGraph.createSubgraph(Session) {
    constant(User("ada"))
}

val controller: LoginController = sessionGraph.instance()

sessionGraph.close()
appGraph.close()

The creator of a subgraph is responsible for closing it. Closing a graph doesn’t close its subgraphs. The Android module closes the subgraphs it creates with the Android lifecycle.

Composing components

Components can be put together from parts, e.g. one per feature module, and each target of an application can compose them differently.

Include

include copies all dependencies of a component. Subcomponents with the same qualifier are merged by default, so several features can contribute to the same subcomponent.

val networkComponent = component {
    singleton { HttpClient() }
}

val loginFeature = component {
    subcomponent(Session) {
        singleton { LoginController(instance(), instance()) }
    }
}

val profileFeature = component {
    subcomponent(Session) {
        singleton { ProfileController(instance()) }
    }
}

val appComponent = component {
    include(networkComponent)
    // Both features contribute to the session subcomponent, it gets merged.
    include(loginFeature)
    include(profileFeature)
}

The second argument of include controls how subcomponents are handled:

SubcomponentIncludeMode Behaviour

Merge (default)

Subcomponents with the same qualifier are merged.

Replace

An existing subcomponent with the same qualifier is replaced.

DoNotIncludeIfAlreadyPresent

A subcomponent is only included if no subcomponent with the same qualifier exists.

DoNotInclude

Subcomponents are not included.

Including a dependency that already exists throws an exception, unless it happens in an override block.

Derive and override

derive creates an extended copy of a component. Existing entries can be replaced in an override block and existing subcomponents can be extended with deriveExisting = true.

val testComponent = appComponent.derive {
    // replace existing entries
    override {
        singleton { HttpClient(baseUrl = "http://localhost:8080") }
    }
    // extend an existing subcomponent
    subcomponent(Session, deriveExisting = true) {
        constant(User("test user"))
    }
}

The application graph

Winter is an application object that holds the application component, the application graph and the plugins. It is the default for the property delegates, the Android module and the test modules.

CoffeeShop.component {
    singleton { Heater() }
    singleton<Pump> { RotaryPump() }
    prototype { CoffeeMaker(instance(), instance()) }

    subcomponent(Session) {
        singleton { ProfileController(instance()) }
    }
}

val appGraph: Graph = CoffeeShop.openGraph()
// anywhere else in the application
val coffeeMaker: CoffeeMaker = CoffeeShop.graph.instance()

// e.g. when the application terminates
CoffeeShop.closeGraph()

Libraries should use their own application object, so that they don’t share their component, graph and plugins with the application:

object CoffeeShop : WinterApplication()

Property delegates

Constructor injection should be preferred. For classes that are created by a framework, e.g. Android activities, the winter-delegate module provides property delegates. An Injector retrieves the dependencies when inject() is called.

class CoffeeScreen {
    private val injector by CoffeeShop

    // retrieved when inject() is called
    private val coffeeMaker: CoffeeMaker by injector()
    // retrieved on first access
    private val heater: Heater by injector.lazyInstance()
    // resolves a new instance on every call
    private val pumpProvider: () -> Pump by injector.provider()
    // maps the instance
    private val pumpName: String by injector<Pump>().map { it.javaClass.simpleName }

    fun onCreate() {
        injector.inject()
    }
}

By default inject() uses the application graph. A graph resolver registered for the class of the injection target can provide another graph, e.g. a new subgraph:

CoffeeShop.component {
    singleton { Heater() }
    singleton<Pump> { RotaryPump() }
    singleton { CoffeeMaker(instance(), instance()) }

    // OrderScreen gets its dependencies from a new screen subgraph
    graphResolver<OrderScreen> { appGraph, screen ->
        appGraph.createSubgraph(ScreenScope)
    }

    subcomponent(ScreenScope) {
        singleton { OrderList(instance()) }
    }
}

Android

The winter-androidx module creates a view model subgraph, that survives configuration changes, and an activity subgraph for each activity instance. Both are closed automatically with the Android lifecycle.

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        Winter.component {
            singletonOf(::GitHubApi)

            // creates the view model and activity subgraphs for RepoListActivity
            activityGraphResolver<RepoListActivity>()

            // survives configuration changes and is closed when the activity is finished
            subcomponent(ViewModelScope) {
                singletonOf(::RepoListViewModel)

                // created for every activity instance and closed when it is destroyed
                subcomponent(ActivityScope) {
                    singleton { ImageLoader(instance()) }
                }
            }
        }

        Winter.openGraph {
            // registers the Application, its Context and the process lifecycle
            application(this@MyApplication)
        }
    }
}
class RepoListActivity : ComponentActivity() {
    private val injector by Winter
    private val viewModel: RepoListViewModel by injector()
    private val imageLoader: ImageLoader by injector()

    override fun onCreate(savedInstanceState: Bundle?) {
        injector.inject()
        super.onCreate(savedInstanceState)
    }
}

The activity graph provides the activity as Activity, Context, ComponentActivity and its own type, as ViewModelStoreOwner, SavedStateRegistryOwner and OnBackPressedDispatcherOwner, and its Lifecycle, lifecycleScope, ViewModelStore, SavedStateRegistry and OnBackPressedDispatcher.

The resolver takes optional builder blocks to add dependencies to the view model and the activity subgraphs:

activityGraphResolver<RepoDetailActivity>(
    // called once per view model subgraph, don't keep a reference to the activity here
    viewModelScope = { activity ->
        constant(RepoId(activity.intent.getStringExtra("repoId")!!))
    },
    // called for every activity instance
    activityScope = { activity ->
        constant<Intent>(activity.intent)
    }
)
The view model subgraph outlives the activity that created it. Don’t register the activity or anything that references it in the viewModelScope block.

Lifecycle

Singletons can follow the process lifecycle or the lifecycle of the nearest Lifecycle in the graph:

// follows the process lifecycle (application component)
singleton { SyncManager() }
    .androidProcessLifecycle(onStart = { it.resume() }, onStop = { it.pause() })

subcomponent(ViewModelScope) {
    subcomponent(ActivityScope) {
        // follows the lifecycle of the activity
        singleton { LocationTracker() }
            .androidLifecycle(onStart = { it.start() }, onStop = { it.stop() })
    }
}

Graph.closeWithLifecycle(lifecycle) closes any graph together with a Lifecycle. WinterContextWrapper attaches a graph to a Context, e.g. to provide a different graph to a view hierarchy.

Testing

The test modules install a plugin for each test (or test class) that can extend and observe all graphs of an application object. This allows replacing dependencies in the graph of the code under test.

JUnit 4

class GreeterTest {

    @get:Rule
    val winterRule = WinterRule {
        // replace the Api in the application graph
        extend(ApplicationScope) {
            override {
                singleton<Api> { FakeApi() }
            }
        }
    }

    @Before
    fun setUp() {
        Winter.component {
            singleton<Api> { RealApi() }
            prototype { Greeter(instance()) }
        }
        Winter.openGraph()
    }

    @After
    fun tearDown() {
        Winter.closeGraph()
    }

    @Test
    fun greets() {
        val greeter: Greeter = Winter.graph.instance()
        assertThat(greeter.greet()).isEqualTo("Hello from the test")
    }

}

JUnit 5

WinterEachExtension installs the plugin for each test, WinterAllExtension once for all tests of a class.

class GreeterTest {

    @JvmField
    @RegisterExtension
    val winterExtension = WinterEachExtension {
        // replace the Api in the application graph
        extend(ApplicationScope) {
            override {
                singleton<Api> { FakeApi() }
            }
        }
    }

    @BeforeEach
    fun setUp() {
        Winter.component {
            singleton<Api> { RealApi() }
            prototype { Greeter(instance()) }
        }
        Winter.openGraph()
    }

    @AfterEach
    fun tearDown() {
        Winter.closeGraph()
    }

    // parameters annotated with @WInject are resolved from the latest opened graph
    @Test
    fun greets(@WInject greeter: Greeter) {
        assertThat(greeter.greet()).isEqualTo("Hello from the test")
    }

}

Plugins

Plugins are registered on an application object and are called for every graph created by it: when a graph is initializing (to extend its component), opened, closed and whenever an instance is created.

val uninstall = CoffeeShop.plugin {
    // derive the component of every new graph with the qualifier Session
    extend(Session) {
        constant(User("guest"))
    }
    // called for every new graph
    withNewGraph { }
    // called for every graph that gets closed
    withClosingGraph { }
    // called for every new instance of Heater
    withNewInstance<Heater> { scope, heater ->
    }
}

// later
uninstall()

Circular dependencies

Two dependencies can’t depend on each other through their constructors. One of them has to receive the other one after it was created, in a post-construct callback. The callbacks are called after the whole dependency request was resolved, so both instances exist at that point.

val graph = graph {
    singleton { Parent(instance()) }
    singleton { Child() }
        .onPostConstruct { it.parent = instance() }
}

Thread safety

Graphs can be used from multiple threads. Instances are created while holding the lock of the graph they belong to. Already created singletons are returned without locking.