Skip to content

mtumilowicz/scala-cats-functional-dependency-injection-workshop

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

39 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Build Status License: GPL v3

scala-cats-functional-dependency-injection-workshop

preface

Reader

  • main purpose: compose functions and delay dependency injection phase until the later moment
  • drawback: combining Reader together with other monads requires to write a bit of boilerplate
    def validateUser(name: Name, age: Age): Reader[Env, Try[User]] =
        for {
          validatedNameTry <- validateName(name)
          validatedAgeTry <- validateAge(age)
        } yield for {
            validatedName <- validatedNameTry
            validatedAge <- validatedAgeTry
          } yield User(validatedName, validatedAge)
    
    private def validateName(name: Name): Reader[Env, Try[Name]]
    
    private def validateAge(age: Age): Reader[Env, Try[Age]]
    
  • solution: ReaderT (monad transformer)
    def validateUser(name: Name, age: Age): ReaderT[Try, Env, User] =
      for {
        validatedName <- validateName(name)
        validatedAge <- validateAge(age)
      } yield User(validatedName, validatedAge)
    
    private def validateName(name: Name): ReaderT[Try, Env, Name]
    
    private def validateAge(age: Age): ReaderT[Try, Env, Age]
    
  • can also be used for passing a "Diagnostic Context"
    • example: add requestId to every log message in your code base
      • make requestId accessible in every function
    • solution
      def saveUser(user: User): = ReaderT[Try, Context, User] { context =>
        logger.debug(s"reqId: ${context.requestId} saveUser ${user}")
        // saving logic goes here
      }
      
      def createUser(name: Name, age: Age): Try[User] =
        (for {
          user <- validateUser(name, age)
          savedUser <- UserRepository.saveUser(user)
        } yield savedUser).run(Context(generateReqId()))
      

monad transformers

  • Monads do not compose, at least not generically
    • note that Functors compose, so we have problem only with flatMap
  • problem
    def findUserById(id: Long): Future[Option[User]] = ???
    def findAddressByUser(user: User): Future[Option[Address]] = ???
    
    def findAddressByUserId(id: Long): Future[Option[Address]] =
      for {
        user    <- findUserById(id) // user has type: Option[User]
        address <- findAddressByUser(user) // error: type mismatch;
      } yield address
    
    or in other words
    val fl: Future[List[Int]] = ??
    
    fl.flatMap(list => list.flatMap(f)) // two maps, one function
    
  • solution
    case class FutureOpt[A](value: Future[Option[A]]) {
    
      def map[B](f: A => B): FutureOpt[B] =
        FutureOpt(value.map(optA => optA.map(f)))
      def flatMap[B](f: A => FutureOpt[B]): FutureOpt[B] =
        FutureOpt(value.flatMap(opt => opt match {
          case Some(a) => f(a).value
          case None => Future.successful(None)
        }))
    }
    
    def findAddressByUserId(id: Long): Future[Option[Address]] =
      (for {
        user    <- FutOpt(findUserById(id))
        address <- FutOpt(findAddressByUser(user))
      } yield address).value
    
  • and suppose we want to have wrapper for List[Future]
    case class ListOpt[A](value: List[Option[A]]) {
    
     def map[B](f: A => B): ListOpt[B] =
        ListOpt(value.map(optA => optA.map(f)))
     def flatMap[B](f: A => ListOpt[B]): ListOpt[B] =
        ListOpt(value.flatMap(opt => opt match {
          case Some(a) => f(a).value
          case None => List(None)
        }))
    }
    
  • conclusion
    • we don’t need to know anything specific about the "outer" Monad
    • we have to know something about "inner" Monad
      • see how we destructured the Option?
    • we could abstract over the "outer" Monad
      • usually named OptionT
      • OptionT[F, A] is a flat version of F[Option[A]] which is a Monad itself

Kleisli

  • Kleisli[F[_], A, B] is just a wrapper around the function A => F[B]
  • Kleisli can be viewed as the monad transformer for functions
    • allows the composition of functions where the return type is a monadic
    • problem
      val parse: String => Option[Int] =
        s => if (s.matches("-?[0-9]+")) Some(s.toInt) else None
      
      val reciprocal: Int => Option[Double] =
        i => if (i != 0) Some(1.0 / i) else None
      
      // you cannot compose it, you have to flatMap them
      def parseAndReciprocal(s: String): Option[Double] = parse(s).flatMap(reciprocal)
      
    • solution (wrapping functions declared above)
      val parseKleisli: Kleisli[Option,String,Int] =
        Kleisli(parse)
      
      val reciprocalKleisli: Kleisli[Option, Int, Double] =
        Kleisli(reciprocal)
      
      val parseAndReciprocalKleisli = parseKleisli andThen reciprocalKleisli
      
  • problem: several functions depend on some environment and we want a nice way to compose these functions to ensure they all receive the same environment
  • example
    val makeDB: Config => IO[Database]
    val makeHttp: Config => IO[HttpClient]
    val makeCache: Config => IO[RedisClient]
    
    def program(config: Config) = for {
      db <- makeDB(config)
      http <- makeHttp(config)
      cache <- makeCache(config)
      ...
    } yield someResult
    
  • solution
    val makeDB: Kleisli[IO, Config, Database]
    val makeHttp: Kleisli[IO, Config, HttpClient]
    val makeCache: Kleisli[IO, Config, RedisClient]
    
    val program: Kleisli[IO, Config, Result] = for {
      db <- makeDB
      http <- makeHttp
      cache <- makeCache
      ...
    } yield someResult
    

izumi Tag

  • is a fast, lightweight, portable and efficient alternative for TypeTag from scala-reflect
  • compiles faster, runs a lot faster than scala-reflect and is fully immutable and thread-safe
  • why we need tags?
    • type erasure - compiler removes all generic type information at compile-time, leaving this information missing at runtime
    • tags are solutions to get the type information of the erased type at runtime
  • example
    • problem: heterogenous map of dependencies
      • service type -> service instance
    • solution
      final case class Has[A](map: Map[String, Any])
      
      implicit class HasOps[Self <: Has[_]](self: Self) {
      
        def get[A](implicit ev: Self <:< Has[A], tag: Tag[A]): A =
          self.map(tag.toString).asInstanceOf[A]
      
        def ++[That <: Has[_]](that: That): Self with That =
          Has(self.map ++ that.map).asInstanceOf[Self with That]
      }