In this continuing series of functional patterns in domain modeling, I will go through yet another idiom which has been a quite common occurrence in my explorations across various domain models. You will find many of these patterns explained in details in my upcoming book on Functional and Reactive Domain Modeling, the early access edition of which is already published by Manning.
One of the things that I strive to achieve in implementing domain models is to use the type system to encode as much domain logic as possible. If you can use the type system effectively then you get the benefits of parametricity, which not only makes your code generic, concise and polymorphic, but also makes it self-testing. But that's another story which we can discuss in another post. In this post I will talk about a pattern that helps you design domain workflows compositionally, and also enables implementing domain invariants within the workflow, all done statically with little help from the type system.
As an example let's consider a loan processing system (simplified for illustration purposes) typically followed by banks issuing loans to customers. A typical simplified workflow looks like the following :-
The Domain Model
The details of each process is not important - we will focus on how we compose the sequence and ensure that the API verifies statically that the correct sequence is followed. Let's start with a domain model for the loan application - we will keep on enriching it as we traverse the workflow.
case class LoanApplication private[Loans]( // date of application date: Date, // name of applicant name: String, // purpose of loan purpose: String, // intended period of repayment in years repayIn: Int, // actually sanctioned repayment period in years actualRepaymentYears: Option[Int] = None, // actual start date of loan repayment startDate: Option[Date] = None, // loan application number loanNo: Option[String] = None, // emi emi: Option[BigDecimal] = None )
Note we have a bunch of attributes that are defined as optional and will be filled out later as the loan application traverses through the sequence of workflow. Also we have declared the class private and we will have a smart constructor to create an instance of the class.
Wiring the workflow with Kleisli
Here are the various domain behaviors modeling the stages of the workflow .. I will be using the scalaz library for the
Kleisli
implementation.def applyLoan(name: String, purpose: String, repayIn: Int, date: Date = today) = LoanApplication(date, name, purpose, repayIn) def approve = Kleisli[Option, LoanApplication, LoanApplication] { l => // .. some logic to approve l.copy( loanNo = scala.util.Random.nextString(10).some, actualRepaymentYears = 15.some, startDate = today.some ).some } def enrich = Kleisli[Option, LoanApplication, LoanApplication] { l => //.. may be some logic here val x = for { y <- l.actualRepaymentYears s <- l.startDate } yield (y, s) l.copy(emi = x.map { case (y, s) => calculateEMI(y, s) }).some }
applyLoan
is the smart constructor that creates the initial instance of LoanApplication
. The other 2 functions approve
and enrich
perform the approval and enrichment steps of the workflow. Note both of them return an enriched version of the LoanApplication
within a Kleisli, so that we can use the power of Kleisli
composition and wire them together to model the workflow ..val l = applyLoan("john", "house building", 10) val op = approve andThen enrich op run l
When you have a sequence to model that takes an initial object and then applies a chain of functions, you can use plain function composition like
h(g(f(x)))
or using the point free notation, (h compose g compose f)
or using the more readable order (f andThen g andThen h)
. But in the above case we need to have effects along with the composition - we are returning Option
from each stage of the workflow. So here instead of plain composition we need effectful composition of functions and that's exactly what Kleisli
offers. The andThen
combinator in the above code snippet is actually a Kleisli
composition aka function composition with effects.So we have everything the workflow needs and clients use our API to construct workflows for processing loan applications. But one of the qualities of good API design is to design it in such a way that it becomes difficult for the client to use it in the wrong way. Consider what happens with the above design of the workflow if we invoke the sequence as
enrich andThen approve
. This violates the domain invariant that states that enrichment is a process that happens after the approval. Approval of the application generates some information which the enrichment process needs to use. But because our types align, the compiler will be perfectly happy to accept this semantically invalid composition to pass through. And we will have the error reported during run time in this case.Remembering that we have a static type system at our disposal, can we do better ?
Phantom Types in the Mix
Let's throw in some more types and see if we can tag in some more information for the compiler to help us. Let's tag each state of the workflow with a separate type ..
trait Applied trait Approved trait Enriched
Finally make the main model
LoanApplication
parameterized on a type that indicates which state it is in. And we have some helpful type aliases ..case class LoanApplication[Status] private[Loans]( //.. type LoanApplied = LoanApplication[Applied] type LoanApproved = LoanApplication[Approved] type LoanEnriched = LoanApplication[Enriched]
These types will have no role in modeling domain behaviors - they will just be used to dispatch to the correct state of the sequence that the domain invariants mandate. The workflow functions need to be modified slightly to take care of this ..
def applyLoan(name: String, purpose: String, repayIn: Int, date: Date = today) = LoanApplication[Applied](date, name, purpose, repayIn) def approve = Kleisli[Option, LoanApplied, LoanApproved] { l => l.copy( loanNo = scala.util.Random.nextString(10).some, actualRepaymentYears = 15.some, startDate = today.some ).some.map(identity[LoanApproved]) } def enrich = Kleisli[Option, LoanApproved, LoanEnriched] { l => val x = for { y <- l.actualRepaymentYears s <- l.startDate } yield (y, s) l.copy(emi = x.map { case (y, s) => calculateEMI(y, s) }).some.map(identity[LoanEnriched]) }
Note how we use the phantom types within the
Kleisli
and ensure statically that the sequence can flow only in one direction - that which is mandated by the domain invariant. So now an invocation of enrich andThen approve
will result in a compilation error because the types don't match. So once again yay! for having the correct encoding of domain logic with proper types.