Domain Modelling with Haskell: Generalizing with Foldable and Traversable
This is the second episode in the short series on Domain Modelling with Haskell. In this episode, we will generalize our domain model from the last episode, providing more fine-grained reporting, with less code.
Show Notes
In the last episode we modelled a basic project management system using Haskell data structures. We had the ability to print project data structures, and to produce a single economic report for an entire project. But the customer wants more fine-grained reporting! They want to see how individual projects are doing; if they are sticking to their budget.
If you haven’t watched the previous episode, I recommend you do so before continuing with this one.
Let’s see what we have already. The calculateProjectReport
function recursively, in a bottom-up fashion, calculates reports and folds them together, using the Monoid
instance.
calculateProjectReport :: Project -> IO Report
calculateProjectReport = calc
where
calc (Project p _) =
calculateReport <$> DB.getBudget p <*> DB.getTransactions p
calc (ProjectGroup _ projects) = foldMap calc projects
What we get back is a single Report
for the entire project tree structure. What we need are reports for each individual project.
Looking at the Project
data type, we see that it’s very concrete, or specialized. There are no slots in the project constructors. In other words, there is no way of extending this data type for use cases we haven’t foreseen.
data Project
= Project ProjectId
Text
| ProjectGroup Text
[Project]
deriving (Show, Eq)
As we control the definition of the data structure, we could of course add more specialized fields that support all the use cases we know of. That approach, however, will bloat the Project
data type as our system grows, and will most likely make it fragile and hard to work with.
Deriving Functor, Foldable, and Traversable
Instead, we will open it up, making it polymorphic. This enables us to reuse the project structure for different features of our system; project configuration forms, a project comparison feature, our reporting module, a navigation tree, a project updates websocket server, just to name a few examples.
We add a type argument a
, remove the specific ProjectId
field, and add a polymorphic field a
to the Project
constructor. As the data type is recursive, we need to use a
when constructing the type for sub-projects.
data Project a
= Project Text
a
| ProjectGroup Text
[Project a]
deriving (Show, Eq, Functor, Foldable, Traversable)
Now, here comes the best part. By having a single type argument for our data type like this, we can derive some very useful instances: Functor, Foldable, and Traversable.
- Functor lets us map a function over the project leaves, retaining its structure.
- Foldable lets us fold the project data structure into a single value, in various ways, given that the element type has a
Monoid
instance, or that we can map each element to a monoid. Folding collapses the structure. - Traversable lets us traverse the project tree and perform an action at each element. The action is usually applicative or monadic.
To derive these three typeclasses, we need to enable two language extensions:
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveTraversable #-}
Calculating and Folding Reports
Instead of directly calculating a single report, we will calculate reports for each leaf project, using Traversable
. Then, with a transformed tree containing reports, we can fold it into a single report.
We need to import fold
from Data.Foldable
:
import Data.Foldable (fold)
We write a new version of calculateProjectReport
. It is defined using traverse
, and the action that is performed at each element calculates a report using the respective project ID.
calculateProjectReports :: Project ProjectId -> IO (Project Report)
calculateProjectReports =
traverse (\p -> calculateReport <$> DB.getBudget p <*> DB.getTransactions p)
Note how we transform a pure value, the project structure of project IDs, using an impure action, and get back an impure action, returning a project structure of pure report values. This is one of the beauties of Traversable
.
Given that we have a project of reports, we can then fold together all those individual reports into a single report, using the Foldable
instance. The accumulateProjectReport
function is simply a specialization of fold
, a method in the Foldable
typeclass.
accumulateProjectReport :: Project Report -> Report
accumulateProjectReport = fold
Using Applicative
and Foldable
, we now have reports for all individual projects, and a way of combining them into a report for the whole project tree. And we did not have to do any explicit recursion ourselves!
Adapting the Pretty Printing and Demo Modules
We need to change our pretty printing, now that we’ve made Project
polymorphic. The asTree
function will convert any Project a
into a tree of string labels, so we need a function a -> String
. We call it prettyValue
. We use that function to print the value x
of a single report project. We also need to pass the prettyValue
function along when recursing.
asTree :: (a -> String) -> Project a -> Tree String
asTree prettyValue project =
case project of
Project name x -> Node (printf "%s: %s" name (prettyValue x)) []
ProjectGroup name projects ->
Node (Text.unpack name) (map (asTree prettyValue) projects)
The prettyReport
will need the same type of function as an argument, to apply asTree
.
prettyProject :: (a -> String) -> Project a -> String
prettyProject prettyValue = drawTree . asTree prettyValue
Our test data in the Demo
module need some changes too. We have changed the order of fields in a single project, so let’s fix that.
someProject :: Project ProjectId
someProject = ProjectGroup "Sweden" [stockholm, göteborg, malmö]
where
stockholm = Project "Stockholm" 1
göteborg = Project "Gothenburg" 2
malmö = ProjectGroup "Malmö" [city, limhamn]
city = Project "Malmö City" 3
limhamn = Project "Limhamn" 4
REPL Example
We can now calculate a project report data structure, and print it nicely.
*Demo> pr <- calculateProjectReport someProject
*Demo> putStrLn (prettyProject prettyReport pr)
Sweden
|
+- Stockholm: Budget: 2082.92, Net: 1338.98, Difference: 3421
|
+- Gothenburg: Budget: -3909.57, Net: -447.81, Difference: +3461
|
`- Malmö
|
+- Malmö City: Budget: 6544.71, Net: 1146.83, Difference: -5397.88
|
`- Limhamn: Budget: -3571.50, Net: 53.15, Difference: +3624.65
We can fold the value into a single report, and print that:
*Demo> putStrLn (prettyReport (accumulateProjectReport pr))
Budget: -3019.27, Net: 2091.15, Difference: +5110.43
OK, it’s time to wrap up! With Functor
, Foldable
, and Traversable
, we can structure our computations using domain-specific data types, and reuse those data types for multiple use cases. We don’t need to handle recursion explicitly, and thus we can focus only on the transformations that we care about. In the next episode, we will get new customer requirements, and evolve our project management system further, using new techniques.
If you like these videos, hit the like button, and don’t forget to subscribe to the Haskell at Work YouTube channel.
Thank you for watching!
Source Code
The source code for the full series is available at github.com/haskell-at-work/domain-modelling-with-haskell.