6

What would it be the best approach to solve this problem in the most functional (algebraic) way by using Scala and Cats (or maybe another library focused on Category Theory and/or functional programming)?

Resources

Provided we have the following methods which perform REST API calls to retrieve single pieces of information?

type FutureApiCallResult[A] = Future[Either[String, Option[A]]]

def getNameApiCall(id: Int): FutureApiCallResult[String]
def getAgeApiCall(id: Int): FutureApiCallResult[Int]
def getEmailApiCall(id: Int): FutureApiCallResult[String]

As you can see they produce asynchronous results. The Either monad is used to return possible errors during API calls and Option is used to return None whenever the resource is not found by the API (this case is not an error but a possible and desired result type).

Method to implement in a functional way

case class Person(name: String, age: Int, email: String)

def getPerson(id: Int): Future[Option[Person]] = ???

This method should used the three API calls methods defined above to asynchronously compose and return a Person or None if either any of the API calls failed or any of the API calls return None (the whole Person entity cannot be composed)

Requirements

For performance reasons all the API calls must be done in a parallel way

My guess

I think the best option would be to use the Cats Semigroupal Validated but I get lost when trying to deal with Future and so many nested Monads :S

Can anyone tell me how would you implement this (even if changing method signature or main concept) or point me to the right resources? Im quite new to Cats and Algebra in coding but I would like to learn how to handle this kind of situations so that I can use it at work.

Community
  • 1
  • 1
  • Have you looked at [Clump](https://github.com/getclump/clump) or [Fetch](https://github.com/47deg/fetch)? – mitchus Jan 09 '18 at 09:30

4 Answers4

19

The key requirement here is that it has to be done in parallel. It means that the obvious solution using a monad is out, because monadic bind is blocking (it needs the result in case it has to branch on it). So the best option is to use applicative.

I'm not a Scala programmer, so I can't show you the code, but the idea is that an applicative functor can lift functions of multiple arguments (a regular functor lifts functions of single argument using map). Here, you would use something like map3 to lift the three-argument constructor of Person to work on three FutureResults. A search for "applicative future in Scala" returns a few hits. There are also applicative instances for Either and Option and, unlike monads, applicatives can be composed together easily. Hope this helps.

Bartosz Milewski
  • 11,012
  • 5
  • 36
  • 45
  • 2
    Can't remember about Cats, but in Scalaz there's a handy ApplicativeBuilder instance available for applicative functors which is constructed via `|@|` symbols and it takes a function to be applied as an argument. It's a generalized version of `map2` for any number of parameters. Code ends up looking something like this: `(getName |@| getAge |@| getEmail)((name, age, email) => Person(name, age, email))` – slouc Jan 08 '18 at 14:15
  • thank you Bartosz for the theoretical explanation. that makes me understand things better. – Enrique Molina Jan 08 '18 at 14:51
  • @Bartosz Milewski, thank you for your answer, it took me a day to figure out the idea, but I understand it now, it is really beautiful – neshkeev Dec 10 '19 at 14:29
8

You can make use of the cats.Parallel type class. This enables some really neat combinators with EitherT which when run in parallel will accumulate errors. So the easiest and most concise solution would be this:

type FutureResult[A] = EitherT[Future, NonEmptyList[String], Option[A]]

def getPerson(id: Int): FutureResult[Person] = 
  (getNameApiCall(id), getAgeApiCall(id), getEmailApiCall(id))
    .parMapN((name, age, email) => (name, age, email).mapN(Person))

For more information on Parallel visit the cats documentation.

Edit: Here's another way without the inner Option:

type FutureResult[A] = EitherT[Future, NonEmptyList[String], A]

def getPerson(id: Int): FutureResult[Person] = 
  (getNameApiCall(id), getAgeApiCall(id), getEmailApiCall(id))
    .parMapN(Person)
Luka Jacobowitz
  • 22,795
  • 5
  • 39
  • 57
  • Sure thing, one other thing I thought I'd mention, is that `EitherNel[String, Option[A]]` looks like a bit of an odd type, because why have an `Option` inside an `Either`. Now you have two ways to specify errors and it makes everything more complicated. Why isn't the `None` case handled by the surrounding `Either`? :) – Luka Jacobowitz Jan 08 '18 at 14:53
  • yes you totally right. this morning i was thinking exactly thr same. better without Option – Enrique Molina Jan 08 '18 at 16:17
  • FWIW, I'll edit the answer to provide a way without Option :) – Luka Jacobowitz Jan 08 '18 at 17:23
  • im trying this code and it tells me that parMapN is not a member of FutureResult[String] in the documentation i see example with monads (Either) but not with monads transformers. am i missing anything? – Enrique Molina Jan 09 '18 at 10:11
  • What Cats version are you using? You need 1.0.0-RC1 or higher – Luka Jacobowitz Jan 09 '18 at 10:55
  • im using it you can find the code in my github (quiquedeveloper) in the project my-cats – Enrique Molina Jan 09 '18 at 11:51
  • I checked your repo and you're using cats 1.0.0-MF, which is a bit outdated, you should be able to upgrade with no problems to 1.0.1 :) – Luka Jacobowitz Jan 09 '18 at 11:54
  • sorry my bad. working now! amazing! as a last question lukas, how would you do it fail fast? (we can obviously ommit error accumulation) – Enrique Molina Jan 09 '18 at 12:04
  • To do fail fast, just use `mapN` instead of `parMapN` :) – Luka Jacobowitz Jan 09 '18 at 13:07
  • i updated my github repo. even changing to mapN it is still not fail fast :( – Enrique Molina Jan 09 '18 at 13:44
  • Ah you are correct, however this is because `Future` is eager and not because cats has the wrong abstraction. If you use something like `IO` or `monix.Task` you'll get fail fast with `mapN` and parallelism with `parMapN`. :) – Luka Jacobowitz Jan 09 '18 at 13:56
  • 1
    cool i will investigate :) thanks a lot for all the examples and the explanations – Enrique Molina Jan 09 '18 at 14:00
0

this is the only solution i came across with but still not satisfied because i have the feeling it could be done in a cleaner way

import cats.data.NonEmptyList
import cats.implicits._

import scala.concurrent.Future

case class Person(name: String, age: Int, email: String)

type FutureResult[A] = Future[Either[NonEmptyList[String], Option[A]]]

def getNameApiCall(id: Int): FutureResult[String] = ???
def getAgeApiCall(id: Int): FutureResult[Int] = ???
def getEmailApiCall(id: Int): FutureResult[String] = ???

def getPerson(id: Int): FutureResult[Person] =
(
  getNameApiCall(id).map(_.toValidated),
  getAgeApiCall(id).map(_.toValidated),
  getEmailApiCall(id).map(_.toValidated)
).tupled // combine three futures
  .map {
    case (nameV, ageV, emailV) =>
      (nameV, ageV, emailV).tupled // combine three Validated
        .map(_.tupled) // combine three Options
        .map(_.map { case (name, age, email) => Person(name, age, email) })   // wrap final result
  }.map(_.toEither)
0

Personally I prefer to collapse all non-success conditions into the Future's failure. That really simplifies the error handling, like:

val futurePerson = for {
  name  <- getNameApiCall(id)
  age   <- getAgeApiCall(id)
  email <- getEmailApiCall(id)
} yield Person(name, age, email)

futurePerson.recover {
  case e: SomeKindOfError => ???
  case e: AnotherKindOfError => ???
}

Note that this won't run the requests in parallel, to do so you'd need to move the future's creation outside of the for comprehension, like:

val futureName = getNameApiCall(id)
val futureAge  = getAgeApiCall(id)
val futureEmail = getEmailApiCall(id)

val futurePerson = for {
  name  <- futureName
  age   <- futureAge
  email <- futureEmail
} yield Person(name, age, email)
James Ward
  • 29,283
  • 9
  • 49
  • 85
  • this is a very simple and clean solution but i see some things i do not like: – Enrique Molina Jan 08 '18 at 19:34
  • I don't think this answers the question, as you don't get error accumulation at all. – Luka Jacobowitz Jan 08 '18 at 19:35
  • True. The for comprehension just fails on the first failure. – James Ward Jan 08 '18 at 19:58
  • 1
    By the defintion of def getPerson(id: Int): Future[Option[Person]] in question there is no need for error accumulation, or am I missing something? The only requirement is to run the futures in parallel. Which is perfectly pragmatic way how @James Ward proposed. – Teliatko Jan 08 '18 at 20:39
  • @James due to the sequential binding of the Futures in the for-comprehension there is not fail-fast (i.e. futureName takes 10 seconds to return a Future Successful whereas futureAge and futureEmail takes 1 to fail, that means that waiting for the final result or person (future failed ) would take 10 seconds). i wonder if failfast can be achieved with another approach like Lukas. what i dont like about this approach is that errors and error handling is not a part of the method signature or the result type and "recover" is not mandatory plus uniform exception handling for different methods – Enrique Molina Jan 09 '18 at 10:14
  • True that this fails sequentially. I think that could be solved by creating a custom `Promise` instead. – James Ward Jan 09 '18 at 14:31