How to model this example
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:
- encoding the classes as functions which makes the code play nicer with Reader
- composing the functions with Reader in a for comprehension and using it
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)
}
Modelling step 1. Encoding classes as functions
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:
- it is clear what kind of dependencies each functionality needs
- hides dependencies of one functionality from another
retainUsers
method should not need to know about the Datastore dependency
Modelling step 2. Using the Reader to compose functions and run them
Reader 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("john.doe@fizzbuzz.com") },
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)()
}
Advantages over using constructor parameters
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.
- Uniformity – no mater how short/long the for comprehension is, it’s just a Reader and you can easily compose it with another
instance, perhaps only introducing one more Config type and sprinkling somelocal
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. - Reader is a monad, so it gets all benefits related to that –
sequence
,traverse
methods implemented for free. - In some cases you may find it preferable to build the Reader only once and use it for wide range of Configs.
With constructors nobody prevents you to do that, you just need to build the whole object graph anew for every Config
incoming. While I have no problem with that (I even prefer doing that on every request to application), it isn’t
an obvious idea to many people for reasons I may only speculate about. - Reader pushes you towards using functions more, which will play better with application written in predominantly FP style.
- Reader separates concerns; you can create, interact with everything, define logic without providing dependencies. Actually supply later, separately. (Thanks Ken Scrambler for this point). This is often heard advantage of Reader, yet that’s also possible with plain constructors.
I would also like to tell what I don’t like in Reader.
- Marketing. Sometimes I get impression, that Reader is marketed for all kind of dependencies, without distinction if that’s
a session cookie or a database. To me there’s little sense in using Reader for practically constant objects, like email
server or repository from this example. For such dependencies I find plain constructors and/or partially applied functions
way better. Essentially Reader gives you flexibility so you can specify your dependencies at every call, but if you
don’t really need that, you only pay its tax. - Implicit heaviness – using Reader without implicits would make the example hard to read. On the other hand, when you hide
the noisy parts using implicits and make some error, compiler will sometimes give you hard to decipher messages. - Ceremony with
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.
What if I don’t want to convert my classes to objects with functions?
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.