I am trying to create a neat construction with for-comprehension for business logic built on futures. Here is a sample which contains a working example based on Exception handling:
(for {
// find the user by id, findUser(id) returns Future[Option[User]]
userOpt <- userDao.findUser(userId)
_ = if (!userOpt.isDefined) throw new EntityNotFoundException(classOf[User], userId)
user = userOpt.get
// authenticate it, authenticate(user) returns Future[AuthResult]
authResult <- userDao.authenticate(user)
_ = if (!authResult.ok) throw new AuthFailedException(userId)
// find the good owned by the user, findGood(id) returns Future[Option[Good]]
goodOpt <- goodDao.findGood(goodId)
_ = if (!good.isDefined) throw new EntityNotFoundException(classOf[Good], goodId)
good = goodOpt.get
// check ownership for the user, checkOwnership(user, good) returns Future[Boolean]
ownership <- goodDao.checkOwnership(user, good)
if (!ownership) throw new OwnershipException(user, good)
_ <- goodDao.remove(good)
} yield {
renderJson(Map(
"success" -> true
))
})
.recover {
case ex: EntityNotFoundException =>
/// ... handle error cases ...
renderJson(Map(
"success" -> false,
"error" -> "Your blahblahblah was not found in our database"
))
case ex: AuthFailedException =>
/// ... handle error cases ...
case ex: OwnershipException =>
/// ... handle error cases ...
}
However this might be seen as a non-functional or non-Scala way to handle the things. Is there a better way to do this?
Note that these errors come from different sources - some are at the business level ('checking ownership') and some are at controller level ('authorization') and some are at db level ('entity not found'). So approaches when you derive them from a single common error type might not work.
The central challenge is that for-comprehensions can only work on one monad at a time, in this case it being the
Future
monad and the only way to short-circuit a sequence of future calls is for the future to fail. This works because the subsequent calls in the for-comprehension are justmap
andflatmap
calls, and the behavior of amap
/flatmap
on a failedFuture
is to return that future and not execute the provided body (i.e. the function being called).What you are trying to achieve is the short-cicuiting of a workflow based on some conditions and not do it by failing the future. This can be done by wrapping the result in another container, let's call it
Result[A]
, which gives the comprehension a type ofFuture[Result[A]]
.Result
would either contain a result value, or be a terminating result. The challenge is how to:Result
Result
is terminatingmap/flatmap
seem like the candidates for doing these types of compositions, except we will have to call them manually, since the onlymap/flatmap
that the for-comprehension can evaluate is one that results in aFuture[Result[A]]
.Result
could be defined as:For each call, the action is really a potential action, as calling it on or with a terminating result, will simply maintain the terminating result. Note that
Terminator
is aResult[Nothing]
since it will never contain a value and anyResult[+A]
can be aResult[Nothing]
.The terminating result is defined as:
The terminating result makes it possible to to short-circuit calls to functions that require a value
[A]
when we've already met our terminating condition.The non-terminating result is defined as:
The non-teminating result makes it possible to provide the contained value
[A]
to functions. For good measure, I've also predefined aUnitResult
for functions that are purely side-effecting, likegoodDao.removeGood
.Now let's define your good, but terminating conditions:
Now we have the tools to create the the workflow you were looking for. Each for comprehention wants a function that returns a
Future[Result[A]]
on the right-hand side, producing aResult[A]
on the left-hand side. TheflatMap
onResult[A]
makes it possible to call (or short-circuit) a function that requires an[A]
as input and we can thenmap
its result to a newResult
:I know that's a whole lot of setup, but at least the
Result
type can be used for anyFuture
for-comprehension that has terminating conditions.You could clean up the for comprehension a little to look like this:
Assuming these methods:
The idea here is to use
flatMap
to turn things likeOptions
that are returned wrapped inFuture
s into failedFuture
s when they areNone
. There are going to be a lot of ways to do clean up that for comp and this is one possible way to do it.Don't use exceptions for expected behaviour.
It's not nice in Java, and it's really not nice in Scala. Please see this question for more information about why you should avoid using exceptions for regular control flow. Scala is very well equipped to avoid using exceptions: you can use
Either
s.The trick is to define some failures you might encounter, and convert your
Option
s intoEither
s that wrap these failures.Using these helpers, you can make your for comprehension readable and exception free: