When asked about Dependency Injection in Scala, quite a lot of answers point to the using the Reader Monad, either the one from Scalaz or just rolling your own. There are a number of very clear articles describing the basics of the approach (e.g. Runar's talk, Jason's blog), but I didn't manage to find a more complete example, and I fail to see the advantages of that approach over e.g. a more traditional "manual" DI (see the guide I wrote). Most probably I'm missing some important point, hence the question.
Just as an example, let's imagine we have these classes:
trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }
class FindUsers(datastore: Datastore) {
def inactive(): Unit = ()
}
class UserReminder(findUser: FindUsers, emailServer: EmailServer) {
def emailInactive(): Unit = ()
}
class CustomerRelations(userReminder: UserReminder) {
def retainUsers(): Unit = {}
}
Here I'm modelling things using classes and constructor parameters, which plays very nicely with "traditional" DI approaches, however this design has a couple of good sides:
UserReminder
has no idea that FindUsers
needs a datastore. The functionalities can be even in separate compile unitsIO
monad if we want to capture the effects etc.How could this be modelled with the Reader monad? It would be good to retain the characteristics above, so that it is clear what kind of dependencies each functionality needs, and hide dependencies of one functionality from another. Note that using class
es is more of an implementation detail; maybe the "correct" solution using the Reader monad would use something else.
I did find a somewhat related question which suggests either:
However, apart from being (but that's subjective) a bit too complex as for such a simple thing, in all of these solutions e.g. the retainUsers
method (which calls emailInactive
, which calls inactive
to find the inactive users) would need to know about the Datastore
dependency, to be able to properly call the nested functions - or am I wrong?
In what aspects would using the Reader Monad for such a "business application" be better than just using constructor parameters?
How could this be modelled with the Reader monad?
I'm not sure if this should be modelled with the Reader, yet it can be by:
Just right before the start I need to tell you about small sample code adjustments that I felt beneficial for this answer.
First change is about FindUsers.inactive
method. I let it return List[String]
so the list of addresses can be used
in UserReminder.emailInactive
method. I've also added simple implementations to methods. Finally, the sample will use a
following hand-rolled version of Reader monad:
case class Reader[Conf, T](read: Conf => T) { self =>
def map[U](convert: T => U): Reader[Conf, U] =
Reader(self.read andThen convert)
def flatMap[V](toReader: T => Reader[Conf, V]): Reader[Conf, V] =
Reader[Conf, V](conf => toReader(self.read(conf)).read(conf))
def local[BiggerConf](extractFrom: BiggerConf => Conf): Reader[BiggerConf, T] =
Reader[BiggerConf, T](extractFrom andThen self.read)
}
object Reader {
def pure[C, A](a: A): Reader[C, A] =
Reader(_ => a)
implicit def funToReader[Conf, A](read: Conf => A): Reader[Conf, A] =
Reader(read)
}
Maybe that's optional, I'm not sure, but later it makes the for comprehension look better. Note, that resulting function is curried. It also takes former constructor argument(s) as their first parameter (parameter list). That way
class Foo(dep: Dep) {
def bar(arg: Arg): Res = ???
}
// usage: val result = new Foo(dependency).bar(arg)
becomes
object Foo {
def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)
Keep in mind that each of Dep
, Arg
, Res
types can be completely arbitrary: a tuple, a function or a simple type.
Here's the sample code after the initial adjustments, transformed into functions:
trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }
object FindUsers {
def inactive: Datastore => () => List[String] =
dataStore => () => dataStore.runQuery("select inactive")
}
object UserReminder {
def emailInactive(inactive: () => List[String]): EmailServer => () => Unit =
emailServer => () => inactive().foreach(emailServer.sendEmail(_, "We miss you"))
}
object CustomerRelations {
def retainUsers(emailInactive: () => Unit): () => Unit =
() => {
println("emailing inactive users")
emailInactive()
}
}
One thing to notice here is that particular functions don't depend on the whole objects, but only on the directly used parts.
Where in OOP version UserReminder.emailInactive()
instance would call userFinder.inactive()
here it just calls inactive()
- a function passed to it in the first parameter.
Please note, that the code exhibits the three desirable properties from the question:
retainUsers
method should not need to know about the Datastore dependencyReader monad lets you only compose functions that all depend on the same type. This is often not a case. In our example
FindUsers.inactive
depends on Datastore
and UserReminder.emailInactive
on EmailServer
. To solve that problem
one could introduce a new type (often referred to as Config) that contains all of the dependencies, then change
the functions so they all depend on it and only take from it the relevant data.
That obviously is wrong from dependency management perspective because that way you make these functions also dependent
on types that they shouldn't know about in the first place.
Fortunately it turns out, that there exist a way to make the function work with Config
even if it accepts only some part of it as a parameter.
It's a method called local
, defined in Reader. It needs to be provided with a way to extract the relevant part from the Config
.
This knowledge applied to the example at hand would look like that:
object Main extends App {
case class Config(dataStore: Datastore, emailServer: EmailServer)
val config = Config(
new Datastore { def runQuery(query: String) = List("[email protected]") },
new EmailServer { def sendEmail(to: String, content: String) = println(s"sending [$content] to $to") }
)
import Reader._
val reader = for {
getAddresses <- FindUsers.inactive.local[Config](_.dataStore)
emailInactive <- UserReminder.emailInactive(getAddresses).local[Config](_.emailServer)
retainUsers <- pure(CustomerRelations.retainUsers(emailInactive))
} yield retainUsers
reader.read(config)()
}
In what aspects would using the Reader Monad for such a "business application" be better than just using constructor parameters?
I hope that by preparing this answer I made it easier to judge for yourself in what aspects would it beat plain constructors. Yet if I were to enumerate these, here's my list. Disclaimer: I have OOP background and I may not appreciate Reader and Kleisli fully as I don't use them.
local
calls on top of it. This point is IMO
rather a matter of taste, because when you use constructors nobody prevents you to compose whatever things you like,
unless someone does something stupid, like doing work in constructor which is considered a bad practice in OOP.sequence
, traverse
methods implemented for free.I would also like to tell what I don't like in Reader.
pure
, local
and creating own Config classes / using tuples for that. Reader forces you to add some code
that isn't about problem domain, therefore introducing some noise in the code. On the other hand, an application
that uses constructors often uses factory pattern, which is also from outside of problem domain, so this weakness isn't that
serious.You want. You technically can avoid that, but just look what would happen if I didn't convert FindUsers
class to object. The respective line of for comprehension would look like:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
which is not that readable, is that? The point is that Reader operates on functions, so if you don't have them already, you need to construct them inline, which often isn't that pretty.
I think the main difference is that in your example you are injecting all dependencies when objects are instantiated. The Reader monad basically builds a more and more complex functions to call given the dependencies, wich are then returned to the highest layers. In this case, the injection happens when the function is finally called.
One immediate advantage is flexibility, especially if you can construct your monad once and then want to use it with different injected dependencies. One disadvantage is, as you say, potentially less clarity. In both cases, the intermediate layer only need to know about their immediate dependencies, so they both work as advertised for DI.
The accepted answer provides a great explanation of how the Reader Monad works.
I would like to add a recipe to compose any two functions having varying dependencies using the Cats Library Reader. This snippet is also available on Scastie
Lets define the two functions that we would like to compose: The functions are similar to those defined in the accepted answer.
case class DataStore()
case class EmailServer()
DataStore
dependency. It takes DataStore
and returns a List of inactive Users def f1(db:DataStore):List[String] = List("[email protected]", "[email protected]", "[email protected]")
EmailServer
as one of the dependency def f2_raw(emailServer: EmailServer, usersToEmail:List[String]):Unit =
usersToEmail.foreach(user => println(s"emailing ${user} using server ${emailServer}"))
Now the recipe to compose the two functions
import cats.data.Reader
val f2 = (server:EmailServer) => (usersToEmail:List[String]) => f2_raw(server, usersToEmail)
Now f2
takes EmailServer
, and returns another function that takes in a List
of users to email
CombinedConfig
class that contains dependencies for the two functions case class CombinedConfig(dataStore:DataStore, emailServer: EmailServer)
val r1 = Reader(f1)
val r2 = Reader(f2)
val r1g = r1.local((c:CombinedConfig) => c.dataStore)
val r2g = r2.local((c:CombinedConfig) => c.emailServer)
val composition = for {
u <- r1g
e <- r2g
} yield e(u)
CombinedConfig
and invoke the composition val myConfig = CombinedConfig(DataStore(), EmailServer())
println("Invoking Composition")
composition.run(myConfig)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With