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 FunctorFoldable, 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.

Scroll to Top