Dualities in Dependency Injection
Hello!
Last email I announced early access sales for the book. I'm both surprised and delighted that so many of you went and purchased a copy. Thank you! It really does mean a lot, and gives me more motivation to get this damn book finished!
I'm in the middle of the chapter on dependency injection, which will be one of the case studies that make up the remaining content. One nice result I've discovered is a duality between the reader monad and "constructor injection" (constructor injection just means passing dependencies as constructor arguments). I thought I'd share it here as a preview of what will be in the chapter.
Let's start with a method with a dependency on a database. Imagine something like
def getUser(db: Database, id: UserId): User
What follows will be a bit clearer if we convert this to a function.
val getUser: (Database, UserId) => User =
(db, id) => ???
We want to remove the Database dependency so we can use getUser without having to pass the Database throughout our code. With the reader monad we say "you can call this method with just the id and supply the Database afterwards". Concretely, this is
val getUser: UserId => Database => User =
id => db => ???
A function from a dependency to a result is exactly the reader monad, so we rewrite this as
val getUser: UserId => Reader[Database, User] =
id => Reader(db => ???)
The reader monad requires we supply the dependency after the call. We might naturally ask what happens if we require it before the call.
val getUser: Database => UserId => User =
database => id => ???
This is inconvenient to work with, but in an object-oriented language we can convert it to
class UserDb(db: Database):
def getUser(id: UserId): User =
???
This is constructor injection. The first parameter to the function becomes a parameter of the constructor. It is semantically equivalent, but easier to work with, than the function version as we have a name, UserDb, for the interface we want to work with.
So we have duality—a bidirectional transformation—between the FP and the OO approach to dependency injection. This shows us they are really the same thing, realized in different ways to account for different language features. Neat!
We can go a bit further. In the reader monad version we converted getUser to
val getUser: UserId => Reader[Database, User] =
id => Reader(db => ???)
In abstract, this looks like
f: A => F[B]
which is the type of function we pass to flatMap, a.k.a. bind. If we follow this trail we see that we can implement Reader as Kleisli, but let's go in a different direction.
The original function was
val getUser: (Database, UserId) => User =
(db, id) => ???
If we convert the parameters to a tuple (Database, UserId) we have something that looks like an instance of the writer monad. Something like
val getUser: Writer[Database, UserId] => User
context => ???
This isn't quite going to work with the writer monad, but the idea is interesting. In abstract we're looking at
f: F[A] => B
which is exactly coflatMap or extract for a comonad! This is the somewhat obscure environment comonad. In plainer English, this is a function from a value of type A, plus some additional context or environment information, to a value of type B.
This reinforces the duality we stated earlier. The environment comonad is the dual of the reader monad, requiring the dependency before the call. It is the exact FP realization of OO constructors.
One more thing before we're done! If you've explored algebraic effects / effect handlers (as seen in Unison, OCaml, and Scala 3) you typically work with functions (in Scala 3 syntax) of type
f: A ->{c, d} B
This means a function that requires a value of type A and capabilities of type c and d, and produces a B. So we're again looking at a function from A, plus some additional context, to B. This means we can view it as comonadic, and related to both the environment comonad and constructor injection. In total we have the reader monad, the environment comonad, object-oriented constructor injection, and effect handlers all as different realizations of one underlying model. Very nice!
Effect handlers are still an active area of research, and there are different formulations of them. I don't want to say every approach to effect handlers is comonadic, but it is certainly useful as a mental model and particularly for the capability-passing approach that Scala 3 uses.
So, that's a quick sketch of some of the main ideas in the chapter I'm working on. It really shows one of the main themes of the book: finding the underlying concepts that unite a bunch of different concepts, allowing us to translate from one to the other as the situation requires.
I hope you find that as interesting as I did.
Till next time!
Noel