Domain Modelling with Haskell: Factoring Out Recursion
In the final part of the “Domain Modelling with Haskell” series we factor out recursion from the Project data type, and use Fixplate to traverse the tree and accumulate reports. Although a highly flexible and powerful technique, it should not be employed prematurely due to its more advanced nature.
Show Notes
Welcome to Haskell at Work, a screencast focused on practical Haskell programming. You’re watching the fourth and final part of Domain Modelling with Haskell.
In the last video, we used explicit recursion and the WriterT monad transformer to accumulate reports on all project levels. We had to add new slots to the Project
data structure to add the additional reporting information. In a way, we have a strange coupling going on, where the Project
module knows how the Reporting
module extends the data structure.
If you only have a couple of these cases in your project, I think you should keep it that way. On the other hand, if you do a lot of transformation and processing based on a core data structure, like our Project
data type, you might want to make it even more generic. Let’s explore one way of doing that.
Factoring Out Recursion
Instead of factoring out specific slots in the Project
and ProjectGroup
data constructors, we will factor out the entire recursion of the data type. We will use a library called Fixplate, whose name is a combination of the term “fixed-point type,” and “Uniplate,” the name of a Haskell package for generic traversals and transformations.
To use Fixplate, and specifcally its fixed-point type, we need to factor out the recursion from our data type. We see that the ProjectGroup
constructor has a list of projects. We will not have that explicit recursion, and instead embed a field of type f
. This technique is also the basis for free monads. If you are interested, check out Gabrial Gonzalez blog post Why free monads matter.
data Project f
= Project ProjectId
Text
| ProjectGroup Text
[f]
deriving (Show, Eq, Functor, Foldable, Traversable)
Note that we can have the ProjectId
in the Project
constructor again.
We’ll rename Project
to ProjectF
, by convention, and define a type alias Project
for Mu ProjectF
.
data ProjectF f
= ...
type Project = Mu ProjectF
We also need to import Mu
and its Fix
constructor:
import Data.Generics.Fixplate.Base (Mu (Fix))
Mu
is the fixed point type. We can have a look in the REPL:
$ stack repl
*Project> import Data.Generics.Fixplate.Base
*Project Data.Generics.Fixplate.Base> :t Fix
Fix :: f (Mu f) -> Mu f
I’m not going to explain in great detail how this works in this quick screencast, so do have a look at Gonzalez’ blog post. But you can see that the Fix
constructor takes a functor f
, parameterized by the fixed point type Mu
itself, and returns a Mu
. So Mu
is responsible for recursively injecting f
into f
itself, wrapped by the Mu
type at each level.
I know, this is a mind bender at first.
We create two helpers for constructing projects and project groups in the fixed-point type.
project :: ProjectId -> Text -> Project
project p = Fix . Project p
projectGroup :: Text -> [Project] -> Project
projectGroup name = Fix . ProjectGroup name
Calculating and Accumulating Reports
Now here comes the first kicker. We want to annotate each level in the tree with the corresponding report. We can do that using Fixplate’s Attr
type (or attribute.) First, we import fold
and the Attr
type.
import Data.Foldable (fold)
import Data.Generics.Fixplate.Base (Attr)
We define a type alias ProjectReport
.
type ProjectReport = Attr ProjectF Report
Then, we rewrite calculateProjectReports
to use a function called synthetiseM
.
import Data.Generics.Fixplate.Attributes (synthetiseM)
synthetiseM
traverses our data structure bottom-up, and uses the function we supply to wrap and annotate each project level with a Report
. The function we supply, calc
, calculates a report for leaf projects. Given a project group, and it folds the underlying reports into a single one.
calculateProjectReports :: Project -> IO ProjectReport
calculateProjectReports = synthetiseM calc
where
calc (Project p _) =
calculateReport <$> DB.getBudget p <*> DB.getTransactions p
calc (ProjectGroup _ reports) = pure (fold reports)
Notice how the second field of ProjectGroup
is a list of reports, the reports below the group that have already been calculated. This, and many other powerful transformations and traversals, are possible thanks to our data type having recursion factored out.
In the end, we get back the full tree, with it’s structure preserved, but with reports on all levels. Before we can try it out, we need to adapt the pretty printing code.
Pretty-Printing with Fixplate
Another big win is that because the recursion is factored out, Fixplate can take care of building the pretty-printable tree for us.
import Data.Generics.Fixplate.Base (Ann (Ann))
All we need to do is to tell it how to print project nodes. prettyResult
is a function from a project annotated with a report, to a string. The Ann
constructor holds the project and the report. A single project is printed as its name, the project ID, and the report. A project group is printed as its name and its report.
prettyResult :: Ann ProjectF Report a -> String
prettyResult (Ann report project') =
case project' of
Project (ProjectId p) name ->
printf "%s (%d): %s" name p (prettyReport report)
ProjectGroup name _ ->
printf "%s: %s" name (prettyReport report)
The project group children are values of the Hole
type, which is used to mark the absence of something to print. Remember, we don’t have to do recursion explicitly.
Trying It Out
We need to change our Demo
data as well. First, we import the Draw
module from Fixplate.
import Data.Generics.Fixplate.Draw
Then, we will use the helper functions to create projects and project groups. The order of fields has changed, so let’s fix that.
someProject :: Project
someProject = projectGroup "Sweden" [stockholm, göteborg, malmö]
where
stockholm = project 1 "Stockholm"
göteborg = project 2 "Gothenburg"
malmö = projectGroup "Malmö" [city, limhamn]
city = project 3 "Malmö City"
limhamn = project 4 "Limhamn"
Now let’s try it out!
We open the REPL, load the Demo
module, calculate a project tree of reports, and draw the tree of reports using prettyResult
.
$ stack repl
...> :l Demo
*Demo> :l Demo
*Demo> pr <- calculateProjectReports someProject
*Demo> drawTreeWith prettyResult pr
\-- Sweden: Budget: -16682.99, Net: -1996.02, difference: +14686.97
|-- Stockholm (1): Budget: -2275.58, Net: -1816.54, difference: +459.04
|-- Gothenburg (2): Budget: -7492.88, Net: +1354.37, difference: +8847.25
\-- Malmö: Budget: -6914.53, Net: -1533.85, difference: +5380.68
|-- Malmö City (3): Budget: -1436.63, Net: +567.36, difference: +2003.99
\-- Limhamn (4): Budget: -5477.90, Net: -2101.21, difference: +3376.69
Same nice results as before, but using a different technique.
Summary
With Fixplate, we have raised the abstraction level, and made our project data type highly extensible and reusable. This does, however, come with a cost, at least in terms of cognitive load for programmers.
I want to warn you of picking this technique up prematurely. Make sure you and your colleagues are comfortable with explicit recursion and monad transformers before considering recursion schemes. I would be careful about introducing this in a work project. The tradeoff is one you have to make yourself. If you are working a lot with similar data structures, recursion schemes, or one of the Uniplate-like libraries, might be a good choice for you.
And that is all for today!
Source Code
The source code for the full series is available at github.com/haskell-at-work/domain-modelling-with-haskell.