Dependency injection is hard, scary and opinionated. So this is "not" a dependency injection library :)
This project is under active development. The API is not stabilized
compile 'com.github.lexer.injektor:injektor:0.0.3'
For unit tests
compile 'com.github.lexer.injektor:injektor-test:0.0.3'
To demonstrate the usage of this library let's do a classic
coffee maker.
For the complete sample code that you can compile and run, see
CoffeeMakerIntegrationTest.
Let's start from defining coffee maker classes:
class CoffeeMaker(override val injector: Injector) : Injectable {
private val heater: Heater by inject()
private val pump: Pump by inject()
private val logger: Logger by inject()
fun brew() {
heater.on()
pump.pump()
logger.log("coffee is brewed")
heater.off()
}
}
interface Heater {
fun on()
fun off()
fun isHot(): Boolean
}
class ElectricHeater(override val injector: Injector) : Injectable, Heater {
private val logger: Logger by inject()
var isOn = false
override fun on() {
logger.log("heater is on")
isOn = true
}
override fun off() {
logger.log("heater is off")
isOn = false
}
override fun isHot(): Boolean {
return isOn
}
}
interface Pump {
fun pump()
}
class Thermosiphon(override val injector: Injector) : Injectable, Pump {
private val heater: Heater by inject()
private val logger: Logger by inject()
override fun pump() {
if (heater.isHot()) {
logger.log("pump is pumping")
}
}
}
Injektor can't resolve undeclared dependencies.
Each dependency should be explicitly declared by implementing the Module
class.
class CoffeeMakerModule : Module() {
// Since Injektor framework rely on property injection
// all you need to pass your factory methods is injector object
override fun configure(injector: Injector) {
bind<Pump> { Thermosiphon(injector) }
bind { CoffeeMaker(injector) }
}
}
By default, the declared dependency will be resolved as a new instance. If you want the reuse instance of a specific class you should use scopes. A deeper dive into scopes is provided in a separate section below.
class HeaterModule : Module() {
override fun configure(injector: Injector) {
bind<Heater> { ElectricHeater(injector) }.scope("coffee")
}
}
You should think of Modules
as "fancy factories".
The actual dependency resolution is done by Injector
s.
Injector
is responsible for dependency resolution and scoping of your instances.
At the moment, only a single Injector
is supported.
In the future, I'm planning to add child injectors for on demand modules support It doesn't mean that you cannot use the library for a multi modular app.
val injector = Injector.create(modules = listof(LoggerModule(), CoffeeMakerModule(), HeaterModule()))
Finally we can resolve our CoffeMaker
dependency and brew some coffee.
There are two ways to resolve dependencies using Injector
.
The first way is using the inject()
delegated property. To enable that property in your class, it
should implement the Injectable
interface
class CoffeeApp : Fragnum, Injectable {
val logger: Logger by inject()
val cofferMaker : CoffeeMaker by inject()
override val injector: Injector
by lazy { Injector.create(modules = listOf(LoggerModule(), HeaterModule(), CoffeeMakerModule())) }
fun run(): Logger {
cofferMaker.brew()
return logger
}
override fun injector(): Injector {
return injector
}
}
The second way is directly calling the get
method on your Injector
instance
val coffeeMaker = injector.get(CoffeeMaker::class)
// or
val coffeeMaker2 : CoffeeMaker = injector.get()
To validate the injector correctness, create a unit test and run the checkInjector
helper function.
checkInjector
will try to instantiate all declared dependencies and eager load all lazy properties.
Example:
class CoffeeMakerModuleTest {
@Test
fun checkinjector_validinjectorWithCompleteGraph_noErrors() {
val injector = Injector.create(modules = listOf(LoggerModule(), HeaterModule(), CoffeeMakerModule()))
assertThat(checkInjector(injector)).isEmpty()
}
Scoping is a mechanism to create singleton instances of your classes with limited lifetime.
To make injector cache instance you class you should use scoped
method in your dependency declaration.
class UserSettingModule : Module() {
override fun configure(injector: Injector) {
bind<UserSettings> { UserSettings() }.scope("user")
}
}
In your code you should explicitly start and stop your scopes.
fun onUserLoggedIn() {
injector.startScope("user")
}
fun onUserLoggedOut() {
injector.stopScope("user")
}
Here are few unit tests to illustrate behavior
@Test
fun sameInstance() {
injector.startScope("user")
val settings1 = injector.get(UserSettings::class)
val settings2 = injector.get(UserSettings::class)
injector.stopScope("user")
assertThat(settings1).isNotNull()
assertThat(settings2).isNotNull()
assertThat(settings1).isEqualTo(settings2)
}
@Test
fun differentInstances() {
injector.startScope("user")
val settings1 = injector.get(UserSettings::class)
injector.stopScope("user")
injector.startScope("user")
val settings2 = injector.get(UserSettings::class)
injector.stopScope("user")
assertThat(settings1).isNotNull()
assertThat(settings2).isNotNull()
assertThat(settings1).isNotEqualTo(settings2)
}
If you want to make your class aware of being destroyed you can implement optional Scoped
interface
class UserSettings: Scoped {
override fun onScopeDestroyed() {
}
}
At this point resolving scoped classes from "not started" scopes is allowed. However it's a very bad behavior and you should avoid it all costs.
Injector logs these attempts as warnings. Please see InjectorLogger
for details.
class CoffeeMakerTest {
@Mock lateinit var pump: Pump
@Mock lateinit var heater: Heater
private lateinit var coffeeMaker: CoffeeMaker
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
coffeeMaker = CoffeeMaker(MockInjector()
.mock(pump)
.mock(heater)
.mock(Logger()))
}
@Test
fun brew() {
coffeeMaker.brew()
verify(heater).on()
verify(pump).pump()
verify(heater).off()
}
}
That's it folks! 🚀 🐑 🚢
Update version and run deploy script below.
$ ./gradlew clean build bintrayUpload -PbintrayUser=BINTRAY_USERNAME -PbintrayKey=BINTRAY_KEY -PdryRun=false
- Why property injection?
Typically it's always recommended to rely on constructor injection. However constructor injection requires unnecessary initialization of all dependencies during construction time. With property injection we can enforce lazy initialization of every individual dependency.
*** You can avoid it with by passing construct arguments as Lazy<>
but
that creates a lot of boilerplate code
- How does this library compare to other DI libraries/frameworks?
This is "not" a di library.
- More robust error messaging needed
- Make sure instantiation is thread safe.
- Aleksei Zakharov (github:lexer)
Copyright 2018 Aleksei Zakharov
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.