cats/cats-effect and webservices function composition

cats/cats-effect and webservices function composition

If you use webservices, say in a CLI program, then you are familiar with the following pattern:

// typing in the types to show what they are...
val entityA: IO[Option[A]] = getAByName("aname")
val entityB: IO[Option[B]] = getBByName("bname")
// ...
// useIt does not return an Option.
def useIt(a: A, b: B, ...): IO[D] = { ... }

// how do you call `useIt`?

Here, the input “aname” comes from the user and is not a primary key (PK). The “get” has to search a list and if it finds a result, returns a single object. Or if the name is not found, it returns None. If the request finds more than 1 value, perhaps it returns None or signals an error (your choice).

This is a classic problem in scala, that is, the effects returned in the get*ByName functions have efffect wrappers and we need to unwrap/drill-into them to use the data inside. There are also a few other other concerns that also come up:

  • Can we find all the issues with the inputs prior to calling useIt so that we can inform the user (it’s a CLI) that there are multiple issues with the parameters they provided?
  • If entityC is needed but requesting it depends on entityA how do we compose that?
  • If we need to return an IO[Unit] instead of whatever useIt returns, how do we do that while still providing output to the user?

cats and cats-effect has machinery to help us out. The documentation suggests that we can treat the “fetching” of values using get*ByName as a “configuration parameter” type problem. cats recommends using Validated to handle this. Let’s see how this might work.

At the end we will discuss whether if all this composition and use of FP structures was worth it. This post uses content covered in the cats documentation but focuses on a specific use-case.

This post does not cover the use-case of serially fetching remote values where each remote value feeds the parameters of the next fetch in a pure “one parent” cascade. That scenario is handled by using multiple for-comprehensions chained together or shoving the effectful values into a common monad structure (which is often quite messy) and using a single for-comprehension.

Returning Effectful Options is Ok, but not great

Our webservice calls return an optional value wrapped in an effect.

def getAByName(n: Name): IO[Option[A]] = {
  // ...
}

We see that the error is carried as a Throwable in IO (see bifunctor for the latest thoughts on representing errors). This allows us to track errors and and return an optional value.

If there is only one input parameter, aname we can easily use this:

getAByName("aname")
.flatMap { _.map { a =>
	useIt(a).flatMap(_ => IO(println(s"$a used.")))
  }
}
.getOrElse(IO(println("No A name found"))

I think this is a bit hard to read, so we could do something like :

getAByName("name")
.flatMap {
    case Some(a) => useit(a).flatMap(_ => IO(println(s"$a used.")))
    case _ => IO(println("No A name found"))
}

Some people do not like writing out the match this way but sometimes it is easier to read.

A problem arises if we have two input parameters but only one is available, what should we do? That would be multiple match statements and the code would be quite long. We need to be more succinct to ensure that the code is still readable. Expanding out matches (or using getOrElse) for multiple input parameters can create alot of boilerplate code very quickly.

Let’s think about the problem with multiple input parameters:

val ioa: IO[Option[A]] = getAByName("aname") // returns Some
val iob: IO[Option[B]] = getBByName("bname") // returns None

// Can't use useIt, print a message and return

We want the None to propagate through naturally so we do not have to write more complicated if-then/match logic explicitly. The obvious thing to do with the outer effect monad, IO, is to use a for-comprehension.

We can use cats and a monad transformer to create a single for-comprehension. Using both of these approaches allows us to pretend that the outer effect, IO, is not present and we are working with Option directly. Unfortunately, the final yield is wrapped in an IO so in the end we have IO[IO[Option[D]]. Remember, useIt does not return an Option just a plain D so we would have to wrap that return value in some way in order to push the final call to useIt into the for-part of the for-comprehension:

// broken out before "for" for illustratation purposes
val ioa = getAByName("aname") 
val iob = getBByName("bname")
val iod = for { 
  a <- OptionT(ioa)
  b <- OptionT(iob)
  // if we wanted an Option out of useIt we could
  // r <- OptionT.liftF(useIt(a,b)) then yield r
} yield useIt(a, b)

To unwrap it to a IO[Unit], our target return type, we can do:

iod.cata(
  IO(println("Either a or b did not have a value")),
  identity
).flatten.void

cata allows us to extract out the value but we had to flatten it to get the value out. Do not forget to import cats.implicits._ to use .flatten and .void directly as syntax. Using .void simply maps the effect to Unit–it hollows out the value but keeps the “shell”.

This approach works but it is not great for the user because we do not know if “aname” or “bname” was invalid. We want check the validity of both. If both are valid, call useIt but if one of them is not valid because their request returned None, we want to print a more helpful message indicating which one failed to help the user more quickly re-issue the CLI command.

Note that some people also believe that monad transformers have issues. See getting rid of the T in MTL for more details. Personally, transfomers feel like yet another layer of wrapping a “strategy/adapter design pattern” around yet another value which at some point will cause confusion and complexity (at least confusion and complexity to me).

Validated and ValidatedNel

cats contains Validated which helps with this use-case. The idea is to use something like an Either so that we can return a user message on the Left or the value on the Right but we need to ensure that for each input parameter, we collect “user messages” across all input parameters. If we have several input parameters and 3 return None but 4 return Some, then we want to print messages about those 3 Nones . “Collecting” each message means that the “Left” needs be a non-empty list of “messages”—in this case a non-empty list of strings. Instead of scala’s Either we should use cats’ ValidatedNel[String, A] which expands out to Validate[NonEmptyList[String], A].

One problem we have is that our input parameters return IO[Option[A]] but we need a IO[ValidatedNel[String, A]]. If we used OptionT(returned valued) to create the transformer for our for-comprehension as described in the previous section, what do we use now?

cats documentation describes a conversion from Option[A] => ValidatedNel[?, A] available in their “syntax” classes. So we can do something like:

val iova = getAByName("aname").map(_.toValidNel[String](s"$aname not found"))
val iovb = getAByName("bname").map(_.toValidNel[String](s"$bname not found"))

Unfortunately, we cannot use this in a for-comprehension because ValidatedNel is not a monad and a for-comprehension requires a “map” and “flatMap” (and “filter”) to use in a “for”.

Let’s think about the problem as “combining” each input parameter’s ValidatedNel together such that a single “invalid” fails the overall “combining” process although each parameter is validated.

cats has a “mapN” function under Applicative that does this. But our ValidatedNel is in an IO and there is no monad transformer for ValdatedNel since ValidatedNel is not a monad. Hence, we need to:

  • Use a for-comprehension to work within the IO monad
  • Use (...some tuple...).mapN( ... => ) inside the yield to combine the ValidationNel instances before applying (mapping) them into the useIt function.

If you only have one parameter you can use just “map” inside the yield because you can map into the “value” side of a ValidatedNel without needing to worry about combining them before hand.

The result of using the “for” and .mapN will be an IO[ValidatedNel[String, IO[D]]] which is fairly complex looking but we can read it as: “the effect that wraps requesting our input parameters leads to either a list of messages indicating the parameters are invalid or a command to run that is wrapped in the same type of effect as the outer effect.”

val iova = getAByName("aname").map(_.toValidNel[String](s"$aname not found"))
val iovb = getBByName("bname").map(_.toValidNel[String](s"$bname not found"))
val iovc = getCByName("cname").map(_.toValidNel[String](s"$cname not found"))
// use for-comprehension to combine independent parameters
// peel off IO
val iod = for { 
  va <- iova
  vb <- iovb
  vc <- iovc
} yield
	// use Applicative to combine ValidatedNels
	// peel off ValidatedNel
	(va,vb,vc).mapN{(a, b, c) =>
		 // other calculations based on a, b, c
		 useIt(a,b,c)
	}
// we have a IO[ValidatedNel[String, IO[D]]], want IO[Unit] that 
// either prints out a message for the user indicating an issue
// with the parameters, or the output of the command useIt.
iod.flatMap(_ fold(
	msglist => IO(msglist.toList.foreach(println)),
	// Could also useIt.flatMap(...IO(println)...) then use identity here
	// The IO in ValidatedNel is "mapped" into.
	_.flatMap(d => IO(println(s"Done! $d")))
))
// or
iod.flatMap {
  case Validated.Invalid(msglist) => IO(msglist.toList.foreach(println))
  case Validated.Valid(outputf) => outputf.map(_ => IO(println(s"Done! $d")))
}

Notice that we had the variable names go from iova to va to a along the way. The variables names clearly suggest that at each level we are peeling away a layer of “container” to get to the inner value.

It is also apparent that all formulations, everywhere, are really about drilling into data structures to perform function composition along the way in either a clever or less-than-clever fashion. Whether you map or wrap (which also maps), it is fundamentally the same thing.

The last line maps into the resulting outer effect and then extracts the final value or, if the input parameters were not found, prints out user messages. It is advised that println should not be run as plain statement but to ensure that the println are wrapped in IOs.

Parameters depending on other parameters

The above assumed that each input parameter was independent of the others, but it is also a common case that one input parameter is dependent on another.

val a = getAByName("aname").map(_.toValidNel[String](s"$aname not found"))
// this does not compile
val b = getBByNameAndA("bname", a.title).map(_.toValidNel[String](s"$bname not found"))

This seems like yet another layer to fold into the mix here. And to some degree that’s true.

The issue is that inside the for-comprehension, the left hand side values are ValidatedNels and hence, we cannot just use them directly as a bare parameter. We can, however, just use the same trick as before with mapN:

val io = for {
  av <- a
  bv <- (av).mapN(a => getBByName("bname", a))
} yield ...

This clearly shows that the cats Applicative mapN combines ValidatedNels together and if they are all Valid calls the function inside the map or returns a ValidatedNel with all the the “messages” combined together–which in this case is a non-empty list of strings. It essentially peels away one layer of data structure.

Evaluating parameters in parallel

One problem with

val iova = getAByName("aname").map(_.toValidNel[String](s"$aname not found"))
val iovb = getBByName("bname").map(_.toValidNel[String](s"$bname not found"))
val iovc = getCByName("cname").map(_.toValidNel[String](s"$cname not found"))
val iod = for { 
  a <- iova
  b <- iovb
  c <- iovc
} yield...

is that the parameters are evaluated in sequential fashion. If these were Futures, which are eager and start processing as soon you create them, they would have started evaluating when declared val io* = ... since they are outside the map/flatMap sugar that for-comprehensions offer. If futures were defined in the “for” they would run sequentially as soon as they are declared.

However, we have IOs which are typically suspended. It is quite common that input parameters can be requested independently of each other, and hence, we want to do that in parallel. How do we do that?

cats has a typeclass Parallel with a (...some tuple).parMapN{ (...) => ... } function that looks like the parallel version of the Applicative that we used for processing the ValidatedNels. We have to make sure that each parameter fetch is asynchronous. See here for details on parMapN.

Clearly, the for-comprehension has to go, so lets substitute the for-comprehension with parMapN and lets ensure that our parameter fetch effects are asynchronous by using the IO.shift *> get*ByName(...) trick described on the documentation page.

val iova = IO.shift *> getAByName(...).map(...)
val iovb = IO.shift *> getBByName(...).map(...)
val iovc = IO.shift *> getCByName(...).map(...)
(iova,iovb,iovc).parMapN{ (va, vb, vc) => 
  (va, vb, vc).mapN{ (a, b, c) => 
	  useIt(a,b,c)
}}

If our IOs were already asynchronous, which you cannot tell from the signatures above, we would not need the shift.

This fomulation is not too bad, it is fairly succinct. Clearly, since we have to drill through 2 layers of monadic/data structures, we see evidence of that in the 2 layers of “map”. Plus we get some parallel evaluation and failure insights that help our CLI users.

Is it worth it?

The alternative to using these functional data structures is use classic if-then statements, scala match statements (or roll your own) or more complicated for-comprehensions most of which is prone to breakage, verboseness, inconsistency or bad cut/paste programming craziness. cats/cats-effect helps codify these patterns to something that is succinct and once learned, easier to reproduce.

The basic idea is not that whether you think that FP programming is useful, but that codifying the pattern is useful. FP happens to help us codify the pattern using FP vocabulary and concepts.

If you use this pattern over and over, then using the patterns as described above is worth it. You will make (and I made) many less mistakes compared to code that does not use these patterns. We also gain the ability to provide more helpful feedback to our CLI users.

Just to remind us we have:

  • Input parameters processed in parallel (where that makes sense).
  • Input parameters provide parameter-specific user messages that are combined with other user messages to be presented to the user if needed.
  • Evaluates the final “command”
  • Is “reactive” all the way down including printing the final user messages.

Full Example

Here’s an example from web services software that I use. Action is a Kleisli that should return a IO[Unit] and dynclient is a web services client that makes a HTTP call and in this case with the map and recover, returns an IO[String]

 val setLogo = Action { config =>
    val name  = config.themes.source.get
    val wname = config.themes.webresourceName.get
    val theme = IO.shift *> getByName(name).map(_.toValidNel[String](s"Theme $name not found."))
    val wr    = IO.shift *> getWebResource(wname).map(_.toValidNel[String](s"Web resource $wname not found."))
    val io = (theme, wr).parMapN { (tv, wv) =>
      (tv, wv).mapN { (t, w) =>
        dynclient
          .associate("themes", t.themeid, "logoimage", "webresourceset", w.webresourceid, true)
          .map(_ => s"Logo updated to $wname on theme $name")
          .recover {
            case NonFatal(e) => s"Unable to set logo for theme $name to $wname."
          }
      }
    }
    io.flatMap{
      case Validated.Invalid(msglist) => IO(msglist.toList.foreach(println))
      case Validated.Valid(msgio) => msgio.map(println)
    }
  }

Comments

Popular posts from this blog

zio layers and framework integration

typescript and react types

dotty+scala.js+async: interesting options