The tl;dr version is available as an example under the name Typeclassless.
In this blog series I will describe a technique that can be used to implicitly pass dependencies down a call chain without having to explicitly pass them as parameters. That way you can achieve complete Inversion of Control, where only the initial caller has to specify on which dependencies the call chain works on, and it also dictates the scope.
Note that this technique is achieved using just vanilla Kotlin, without any libraries, frameworks, or annotation processing. It's so awesome that we will apply it to Λrrow shortly!
The basics
Dependency injection is a fancy name for parameter passing, whether it is to a class constructor or a function.
Coming from a Java background we're used to prescriptive advice to avoid any large number of parameters being passed to a function . It's a "code smell", and "you're a bad engineer" if you engage on it (????). Over time there came two organic solutions:
Context object
This style requires bundling all dependencies on a Context
class that is just passed around to functions and constructors. This is quite common in places like the Android Framework.
The upsides of this model is an elegant simplicity in the initial implementation, and the lack of any complexity or machinery required to make it work.
When controlled by us it can be defined as an interface and, with the help of a mocking framework, we can create versions of this dependency bundle relevant to our tests.
On the downside, this Context
object is pervasive through API calls, either implicitly or explicitly, and has the aditional annoyance that you have to be careful to make sure it is not cached or retained outside of its intended scope, else it may transitively retain unwanted dependencies.
Dependency Injection frameworks
Using a framework or library that's responsible of instantiating and storing your dependencies. These frameworks usually come attached to multiple user guides on how to use them, generally surrounding annotation processing, module-dependency definition, and scoping to avoid leaks.
These frameworks trade the one-off complex task of writing the dependency tree and the scopes, in exchange for being able to implicitly receive values that are correctly scoped.
Some frameworks like Google's Dagger 2 also provide you with compile-time assurance that your dependency tree is correct, as in, all dependencies are fulfilled and there are no cycles.
Dependencies as Types
The inspiration came from an idea Raul Raja had for Λrrow, and had already been experimented with in the DI framework Kapsule.
My objective here is to get the best of both worlds: the simplicity of the Context class with the implicit value passing of the DI framework, and have it all checked at compile time.
The key to this technique is to use extension functions, specifically their need to be resolved statically, to make the compiler be our dependency injector.
For that, first we need to build an intuition about how extension functions (extfuns from now on) can work for you.
Your extfuns are based on the idea that the receiver gets the parameters and does something with them, like any other function. What's called syntactic sugar, and what libraries like KTX provide.
In this example, the implicit parameter DaoDatabase
uses the explicit parameter User
to retrieve a value from a database.
fun DaoDatabase.queryUser(id: User) =
this.query("SELECT * FROM Users WHERE userId = ${user.id}")
...
val database = FrameworkModule().provideDao();
database.queryUser(User("123"));
The first disruption in this technique is to flip around the way parameters work. What we want now is for the explicit parameter to use the implicit parameter. For that we rewrite our extfun as:
fun User.queryUser(dao: DaoDatabase) =
dao.query("SELECT * from Users where userId = ${this.id}")
This may raise some eyebrows as it goes against common intuition in both OOP and FP. It gives too much power to what is supposed to be a dumb data class, and pollutes the global namespace with a new function that's not relevant in all domains. Let's fix that!
What we'll do is create an interface to define all operations available for a DaoDatabase
that we will suffix with -Syntax.
When you define an extension function inside an interface, that function is only available within the scope of the implementer of the interface.
This limits scope leaks, prevents these functions to show up in the list of autocomplete suggestions, and we can even make the DaoDatabase
parameter disappear from all functions by requiring it. A win-win.
interface DaoOperationsSyntax {
val dao: DaoDatabase
fun User.queryUser() =
dao.query("SELECT * from Users where userId = ${this.id}")
fun Company.queryCompany() =
dao.query("SELECT * from Companies where companyId = ${this.id}")
}
We can also combine them together to form new types. Let's create a new NetworkSyntax
interface.
interface NetworkSyntax {
val network: NetworkModule
fun User.requestUser() =
network.request(this.id)
}
And an interface that's a combination of Network + Dao:
interface RequestSyntax: DaoOperationsSyntax, NetworkSyntax {
fun User.fetchUser() =
Try { queryUser() }.getOrElse { requestUser() }
}
Wait, have you seen that? in fetchUser()
we have been able to call both requestUser()
and queryUser()
without passing any parameters in neither an implicit or explicit position! The kotlin compiler is smart enough to insert the User
dependency that's bound to this
into both calls.
The compiler rewrites extension function calls inside extension functions to not require the class they're declared for as long as both extension functions are declared for the same class.
We have converted a function with 2 dependencies into a scoped function with 0 explicit parameters. These functions will be available to call using 0 explicit parameters anywhere within the scope of the corresponding RequestSyntax
, NetworkSyntax
, or DaoOperationsSyntax
.
Let's see how to use these syntax interfaces in client code. For that we will revert back to the common intuition of implicit parameters using explicit parameters.
Typed Dependencies in Functions
The simplest and more useful application of this style is by limiting the Syntax to individual functions. That's all what FP is about anyway.
We can define an API object that contains all the functions related to our data services layer.
object DataRepository {
fun RequestSyntax.getUser(user: User) =
user.fetchUser()
fun DaoOperationsSyntax.getCompany(company: Company) =
company.queryCompany()
}
See how the Syntax requirements propagate across function callers here? We'll always need to declare the dependency on the Syntax for all of the functions on the call chain all the way to the origin.
It is now the responsibility of the original caller to provide the appropriately scoped versions of these Syntax objects.
Scoping
Scoping is based off where the Syntax interface is implemented as an object and stored as a value. That is now the equivalent of your "Context object", or your "DI module".
As there is only one reference to the Syntax object that's passed around across layers instead of being retained, it's easy to track its lifecycle and manage it.
Assuming this Syntax object is completely stateless and it lives at the global scope, a simple static value suffices:
import com.pacoworks.DataRepository.getUser
val globalSyntax = object: RequestSyntax {
override val network = GlobalNetwork()
override val dao = DaoSingletonObject
}
fun main(args: Array<String>) {
println(globalSyntax.getUser(User("123")))
}
Let's introduce some nuance and assume the scope is now per-screen, as you would with Android activities. The expected lifecycle of the Syntax object is the same as the Activity, and gets garbage collected alongside it.
import com.pacoworks.DataRepository.getUser
import com.pacoworks.DataRepository.getCompany
class SettingsActivity: Activity {
val activityScopedSyntax = object: RequestSyntax {
override val network = MyNetwork(this)
override val dao = MyDao(this)
}
override fun onResume() {
user.text = activityScopedSyntax.getUser(User("123"))
company.text = activityScopedSyntax.getCompany(Company("123"))
}
}
To recap, to scope efficiently means that you have to manually create and store your Syntax object once, at the origin of the scope, and can now implicitly pass it around across all other layers without explicitly using it as a parameter. Each function is also limited to only use the dependencies that are relevant to itself.
Testing
Same as with scoping, testing individual functions at any layer is now trivial. The standard functions run
, with
, and apply
are the way the Kotlin language provides to execute functions as if they were inside the scope of an object, as it binds the this
parameter to the object they're being called on.
Let's write a simple test Syntax object:
val testNetworkSyntax = object: NetworkSyntax {
override val network = MockNetwork()
}
You can test the Syntax classes simply by implementing their interfaces and running their extension functions.
@Test
fun testNetwork {
val user = testNetworkSyntax.run { User("123").queryUser() }
user shouldBe UserDto(id = "123")
}
And any of the function implementors by importing them:
import com.pacoworks.DataRepository.getUser
@Test
fun testDataRepository {
val user = testNetworkSyntax.getUser(User("123"))
user shouldBe UserDto(id = "123")
}
Recap
The purpose of this blog entry was to showcase a technique to implicitly pass parameters across layers in a way that:
- is checked at compile time - you cannot call the extfuns without a well formed Syntax object
- respects scoping - the caller is responsible of the lifecycle of the Syntax object
- is not cumbersome to the programmer - implicit parameters inside extfuns are provided by the compiler
This is achieved by defining these Syntax types that exclusively use extfuns to describe their dependencies without explicitly requiring them as parameters. You can easily compose types that depend on each other to provide new functionality by creating a new Syntax interface. The composition propagates all the way up to the interface that's finally implemented as an object at the required scope. That scope origin can be local, global, or even just for tests.
This concludes the first part of this short blog series about what I've been calling the Typeclassless technique for Inversion of Control. You may be asking yourself why such a weird name, how does it relate to Λrrow, or how to apply this technique for classes too. For that you can continue on Part 2.
Meanwhile, me and the other Λrrow contributors can be found in twitter, Gitter, or Github.