mapReduce
function and its various specializations by supplying different concrete instances of the Monoid
algebra.
In this post we will take a look at the other end of the spectrum in designing functional domain models. We will discuss evaluation semantics of model behaviors - the precise problem of when to commit to specific concrete evaluation semantics. Consider the following definition of a domain service module ..
type ErrorOr[A] = Either[String, A] trait PaymentService { def paymentCycle: ErrorOr[PaymentCycle] def qualifyingAccounts(paymentCycle: PaymentCycle): ErrorOr[Vector[Account]] def payments(accounts: Vector[Account]): ErrorOr[Vector[Payment]] def adjustTax(payments: Vector[Payment]): ErrorOr[Vector[Payment]] def postToLedger(payments: Vector[Payment]): ErrorOr[Unit] }Such definitions are quite common these days. We have a nice monadic definition going on which can be composed as well to implement larger behaviors out of smaller ones ..
def processPayments() = for { p <- paymentCycle a <- qualifyingAccounts(p) m <- payments(a) a <- adjustTax(m) _ <- postToLedger(a) } yield ()Can we improve upon this design ?
Committing to the concrete early - the pitfalls ..
One of the defining aspects of reusable abstractions is the ability to run it under different context. This is one lesson that we learnt in the last post as well. Make the abstractions depend on the least powerful algebra. In this example our service functions returnEither
, which is a monad. But it's not necessarily the least powerful algebra in the context. Users may choose to use some other monad or may be even applicative to thread through the context of building larger behaviors. Why not keep the algebra unspecified at the service definition level and hope to have specializations in implementations or even in usages at the end of the world ? Here's what we can do ..
// algebra trait PaymentService[M[_]] { def paymentCycle: M[PaymentCycle] def qualifyingAccounts(paymentCycle: PaymentCycle): M[Vector[Account]] def payments(accounts: Vector[Account]): M[Vector[Payment]] def adjustTax(payments: Vector[Payment]): M[Vector[Payment]] def postToLedger(payments: Vector[Payment]): M[Unit] }A top level service definition that keeps the algebra unspecified. Now if we want to implement a larger behavior with monadic composition, we can do this ..
// weaving the monad def processPayments()(implicit me: Monad[M]) = for { p <- paymentCycle a <- qualifyingAccounts(p) m <- payments(a) a <- adjustTax(m) _ <- postToLedger(a) } yield pNote that we are using only the monadic bind in composing the larger behavior - hence the least powerful algebra that we can use is that of a
Monad
. And we express this exact constraint by publishing the requirements of the existence of an instance of a Monad
for the type constructor M
.
What about Implementation ?
Well, we could avoid the committment to a concrete algebra in the definition of the service. What about the implementation ? One of the core issues with the implementation is how you need to handle errors. This is an issue which often makes you commit to an implementation when you write the interpreter / implementation of a service contract. You may useFailure
for a Try
based implementation, or Left
for an Either
based implementation etc. Can we abstract over this behavior through a generic error handling strategy ? Some libraries like cats offers you abstractions like MonadError
that helps you implement error reporting functionality using generic monadic APIs. Here's how we can do this ..
class PaymentServiceInterpreter[M[_]](implicit me: MonadError[M, Throwable]) extends PaymentService[M] { //.. def payments(accounts: Vector[Account]): M[Vector[Payment]] = if (accounts.isEmpty) me.raiseError( new IllegalArgumentException("Empty account list")) else //.. //.. } //.. }Note we needed a monad with error handling capabilities and we used
MonadError
for that. Note that we have kept the error type in MonadError
as Throwable
, which may seem a bit unethical in the context of pure functional programming. But it's also true that many libraries (especially Java ones) or underlying abstractions like Future
or Try
play well with exceptions. Anyway this is just a red herring though it has nothing to do with the current topic of discussion. The moot point is that you need to supply a MonadError
that you have instances of.
Here's how cats defines the trait
MonadError
..
trait MonadError[F[_], E] extends ApplicativeError[F, E] with Monad[F] { //.... and that's exactly what we will commit to. We are still dealing with a generic
Monad
even in the implementation without committing to any concreate instance.
End of the World!
The basic purpose why we wanted to delay committing to the concrete instance was to allow the users the flexibility to choose their own implementations. This is what we call the principle of delayed evaluation. Abstract early, evaluate late and decouple the concerns of building and the evaluation of the abstractions. We have already seen the 2 of these principles - we will see that our design so far will accommodate the third one as well, at least for some instances ofM
.
The user of our API has the flexibility to choose the monad as long as she supplies the
MonadError[M, Throwable]
instance. And we have many to choose from. Here's an example of the above service implementation in use that composes with another service in a monadic way and choosing the exact concrete instance of the Monad
at the end of the world ..
import cats._ import cats.data._ import cats.implicits._ // monix task based computation object MonixTaskModule { import monix.eval.Task import monix.cats._ val paymentInterpreter = new PaymentServiceInterpreter[Task] val emailInterpreter = new EmailServiceInterpreter[Task] for { p <- paymentInterpreter.processPayments e <- emailInterpreter.sendEmail(p) } yield e } // future based computation object FutureModule { import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global val paymentInterpreter = new PaymentServiceInterpreter[Future] val emailInterpreter = new EmailServiceInterpreter[Future] for { p <- paymentInterpreter.processPayments e <- emailInterpreter.sendEmail(p) } yield e } // try task based computation object TryModule { import scala.util.Try val paymentInterpreter = new PaymentServiceInterpreter[Try] val emailInterpreter = new EmailServiceInterpreter[Try] for { p <- paymentInterpreter.processPayments e <- emailInterpreter.sendEmail(p) } yield e }Monix
Task
is an abstraction that decouples the building of the abstraction from execution. So the Task
that we get from building the composed behavior as in the above example can be executed in a deferred way depending of the requirements of the application. It can also be composed with other Tasks to build larger ones as well.
Vertical Composition - stacking abstractions
When you have not committed to an implementation early enough, all you have is an unspecified algebra. You can do fun stuff like stacking abstractions vertically. Suppose we want to implement auditability in some of our service methods. Here we consider a simple strategy of logging as a means to audit the behaviors. How can we take an existing implementation and plugin the audit function selectively ? The answer is we compose algebras .. here's an example that stacks theWriter
monad with an already existing algebra to make the payments
function auditable ..
final class AuditablePaymentService[M[_]: Applicative](paymentService: PaymentService[M]) extends PaymentService[WriterT[M, Vector[String], ?]] { def paymentCycle: WriterT[M, Vector[String], PaymentCycle] = WriterT.lift(paymentService.paymentCycle) def qualifyingAccounts(paymentCycle: PaymentCycle): WriterT[M, Vector[String], Vector[Account]] = WriterT.lift(paymentService.qualifyingAccounts(paymentCycle)) def payments(accounts: Vector[Account]): WriterT[M, Vector[String], Vector[Payment]] = WriterT.putT(paymentService.payments(accounts))(accounts.map(_.no)) //.. } val auditablePaymentInterpreter = new AuditablePaymentService[Future]( new PaymentServiceInterpreter[Future] )We took the decision to abstract the return type in the form of a type constructor early on. But committed to the specific type only during the actual usage of the service implementation. Early abstraction and late committment to implementation make great composition possible and often in ways that may pleasantly surprise you later ..