Reader Pattern

5 minute read

We already introduced Reader Monad, It can inject dependency to function by returning a Reader effect.

In this blog, let’s call the first type parameter of Reader Monad as context. we will put dependencies into the context and use Reader Monad to implement the requirement in Cake Pattern

Context with minimum dependencies

Not like transaction in Reader Monad, we need to pass more than one dependency here, which can’t be done directly by Reader Monad. So we need to create a new type as context to wrap all the dependencies.

case class Env(http: HttpRequest, database: Database)

To use Reader Monad, we also need to modify the function return type of DataSource and DataStore.

trait DataSource[A] {
  def getData: Reader[A, List[Int]]
}

trait DataStore[A] {
  def save(data: List[Int]): Reader[A, Unit]
}

Then we can specify A as Env in the implementation

class HttpDataSource extends DataSource[Env] {
  override def getData: Reader[Env, List[Int]] =
    for {
      env <- Reader.ask[Env]
    } yield env.http.get("http://example.com/data").split(",").map(_.toInt).toList
}

class DatabaseStore extends DataStore[Env] {
  override def save(data: List[Int]): Reader[Env, Unit] =
    for {
      env <- Reader.ask[Env]
    } yield env.database.runSql(
      s"insert into data_table values(${data.mkString(",")})"
    )
}

Because DataJob don’t need to access the dependencies in Env, we can leave context as A

class DataJob[A](
    source: DataSource[A],
    store: DataStore[A],
    encoder: DataEncoder
) {
  def run: Reader[A, Unit] =
    for {
      data <- source.getData
      val encodedData = encoder.encode(data)
      _ <- store.save(encodedData)
    } yield ()
}

Then the dependencies can be wired in main like this

object Main {
  def main() {

    val http = new LogHttpRequest()
    val database = new LogDatabase()
    val env = Env(http, database)

    val source = new HttpDataSource

    val store = new DatabaseStore

    val encoder = new PlusOneEncoder()

    val program = new DataJob[Env](source, store, encoder)

    program.run.run(env)
  }
}

You can find the full code in reader-pattern-basic-in-context.scala

Not like parameter pattern and cake pattern

  • HttpDataSource doesn’t depend on LogHttpRequest directly
  • DatabaseStore doesn’t depend on LogDatabase directly
  • All the dependencies(env) is injected at the last step

Except DataJob, we can instantiated other components separately.

Context with maximum dependencies

Maybe you will ask, could we also put source, store and encoder into context? Then DataJob can also be instantiated separately.

Let’s try

case class Env[A](http: HttpRequest, database: Database, source: DataStore[A], store: DataStore[A], encoder: DataEncoder)

DataStore and DataSource need a type parameter here, so we add a type parameter A to Env. Then our implementation could be

class HttpDataSource extends DataSource[Env[???]] {
  override def getData: Reader[Env[???], List[Int]] =
    for {
      env <- Reader.ask[Env[???]]
    } yield env.http.get("http://example.com/data").split(",").map(_.toInt).toList
}

class DatabaseStore extends DataStore[Env[???]] {
  override def save(data: List[Int]): Reader[Env[???], Unit] =
    for {
      env <- Reader.ask[Env[???]]
    } yield env.database.runSql(
      s"insert into data_table values(${data.mkString(",")})"
    )
}

We use Env[???] here, because we don’t know its final type. It may looks like Env[Env[Env[Env[....]]]], which is a dead loop.

To compile the code, maybe we can hard code the type parameter in Env

case class Env(http: HttpRequest, database: Database, source: DataStore[Env], store: DataStore[Env], encoder: DataEncoder)

Then the implementation could be

class HttpDataSource extends DataSource[Env] {
  override def getData: Reader[Env, List[Int]] =
    for {
      env <- Reader.ask[Env]
    } yield env.http.get("http://example.com/data").split(",").map(_.toInt).toList
}

class DatabaseStore extends DataStore[Env] {
  override def save(data: List[Int]): Reader[Env, Unit] =
    for {
      env <- Reader.ask[Env]
    } yield env.database.runSql(
      s"insert into data_table values(${data.mkString(",")})"
    )
}

The DataJob become

class DataJob {
  def run: Reader[Env, Unit] =
    for {
      env <- Reader.ask[Env]
      data <- env.source.getData
      val encodedData = env.encoder.encode(data)
      _ <- env.store.save(encodedData)
    } yield ()
}

Now we can instantiated all the components separately

object Main {
  def main() {

    val http = new LogHttpRequest()
    val database = new LogDatabase()
    val source = new HttpDataSource
    val store = new DatabaseStore
    val encoder = new PlusOneEncoder()
    val program = new DataJob

    val env = Env(http, database, source, store, encoder)
    program.run.run(env)
  }
}

You can find the full code in reader-pattern-all-in-context.scala

Most of the code looks better now

  • Don’t need to modify HttpDataSource and DatabaseStore.
  • Add one line env <- Reader.ask[Env] in DataJob, but we don’t need to care about class parameter anymore.
  • Components don’t need to depend on each other explicitly
  • The main is simpler, just instantiated all the components and put them into Env.

But we need to pay the price

  • There are circular type dependency in Env, which is harder to understand
  • No restriction to use the memebers of Env, which is easy to make mistake.

    For example, we can even invoke store.save in the implementation

    class DatabaseStore extends DataStore[Env] {
      override def save(data: List[Int]): Reader[Env, Unit] =
        for {
          env <- Reader.ask[Env]
          _ <- env.store.save(data) // recursive call
        } yield env.database.runSql(
          s"insert into data_table values(${data.mkString(",")})"
        )
    }
    
  • The Env will become bigger and bigger, which is hard to maintain
  • The dependency graph is not clear, we need to dig into the code to find out it.

Seems the price is too high, we think context with minimum dependencies is better, we’d better only put the components without any dependency into the context.

Refinement

Based on context with minimum dependencies, we still have two pain points

  • Even we only put components without dependency into context, we still inject too many things. If someone call other dependencies, we can’t know easily except digging into the code. Could we just inject what we need?

  • When we do unit test for components, such as HttpDataSource and DatabaseStore, we have to prepare all the data of context, but we just want to use part of them.

Let’s recall the Implicit usage scenarios, it can apply restriction to the type parameter. Since we don’t want to inject the whole context, then let’s use type parameter + implicit to filter the dependencies we need.

Imagine there is a type A, we expect it has a variable http: HttpRequest, how do we apply the restriction? Right, we can require a function A => HttpRequest together with A

def getData[A](a: A)(getHttpRequest: A => HttpRequest) = 
  getHttpRequest(a).get("http://example.com/data").split(",").map(_.toInt).toList

But the signature of this function is too common, it can not highlight our purpose, let’s use Type Class to make it more targeted.

trait HasHttpRequest[A]{
  def getHttpRequest(a: A): HttpRequest
}

def getData[A: HasHttpRequest](a: A) = 
 implicitly[HasHttpRequest[A]].getHttpRequest(a).get("http://example.com/data").split(",").map(_.toInt).toList

Then for Env we can implement the type class to get http request

object Env {
  implicit object EnvHasHttpRequest extends HasHttpRequest[Env] {
    override def getHttpRequest(v: Env): HttpRequest = v.http
  }
}

Compared to def getData(a: Env), def getData[A: HasHttpRequest](a: A) don’t care about the real type of A, It just require A implement the HasHttpRequest type class.

Use this way, the components will only know the dependencies which are declared to access and we just need to mock the data we need in test

case class MockEnv(http: HttpRequest)
object MockEnv {
  implicit object MockEnvHasHttpRequest extends HasHttpRequest[MockEnv] {
    override def getHttpRequest(v: MockEnv): HttpRequest = v.http
  }
}

You can find the full code in reader-pattern.scala

Summary

Reader Pattern can do dependency injection easily and it can restrict the dependency scope very well. But we can’t put all the dependencies into context, it will be harder and harder to maintain with code growing. It’s fine to just put the dependencies without dependency into context, which minimise the probability of mistake.

Comments