Domain Modelling with Haskell: Accumulating with WriterT
This is the third episode in the short series on Domain Modelling with Haskell. Now our hypothetical customer requires reporting not only at the level of individual projects, but also on the project group level. Our previous solution using Traversable and Foldable won’t help us much, so we use explicit recursion and the WriterT monad transformer to accumulate calculated reports as we traverse the project tree structure.
Show Notes
Welcome to Haskell at Work, previously known as CODA, a screencast focused on practical Haskell programming. You’re watching the third part of Domain Modelling with Haskell.
So far, we have built a basic domain model for the project management system, we have used Traversable
to calculate a project structure of reports for individual projects, and we have used Foldable
to collapse those reports into a single report.
We have a new customer requirement, but first, I want to correct a mistake in our existing code.
Using Decimal instead of Double
To keep the videos simple, I used the Double
data type, a floating-point type, for money. That is most likely not something you would want to do in a real system for financial values, and we should not do it here either. We are going to use the Decimal package, but you might also consider the Fixed or Rational types, which are in base.
Let’s begin by adding the Decimal package as a dependency.
build-depends:
base >=4.7 && <5
, containers
, Decimal
, fixplate
, mtl
, random
, text
In the Project
module we import the Decimal
type from Data.Decimal
, and use it instead of Double
. We need to restart the REPL for the Decimal package to be available.
import Data.Decimal (Decimal)
import Data.Text (Text)
newtype Money = Money
{ unMoney :: Decimal
} deriving (Show, Eq, Num)
In the Database
module, which is a faked implementation generating random budgets and transactions, we now need to generate random decimals. Decimal
doesn’t have an instance of Random
, so we will generate random Double values and convert them. Precision is not important here.
We need the realFracToDecimal
conversion function from Data.Decimal
.
import Data.Decimal (realFracToDecimal)
Before it gets too messy, we will create a random money generating function called randomMoney
. Given a range, it generates a random Double
and converts that into a Money
with precision 2.
randomMoney :: (Double, Double) -> IO Money
randomMoney range =
Money . realFracToDecimal 2 <$> getStdRandom (randomR range)
We rewrite getBudget
and getTransactions
to use randomMoney
.
getBudget :: ProjectId -> IO Budget
getBudget _ = do
income <- randomMoney (0, 10000)
expenditure <- randomMoney (0, 10000)
pure Budget {budgetIncome = income, budgetExpenditure = expenditure}
getTransactions :: ProjectId -> IO [Transaction]
getTransactions _ = do
sale <- Sale <$> randomMoney (0, 4000)
purchase <- Purchase <$> randomMoney (0, 4000)
pure [sale, purchase]
The prettyReport
function needs some changes. Instead of using printf’s floating-point formatting, we will use Decimal’s Show
instance and the roundTo
function, which we need to import.
import Data.Decimal (roundTo)
Let’s write a new function prettyMoney
, from Money
to String
. It rounds the Decimal to a precision of 2 decimal numbers, and adds a “+” sign in front if the number is positive. The Decimal show instance already adds a “-” sign if negative.
prettyMoney :: Money -> String
prettyMoney (Money d) = sign ++ show (roundTo 2 d)
where
sign =
if d > 0
then "+"
else ""
We can change all unMoney
to prettyMoney
, and change the printf
format string to use strings.
prettyReport :: Report -> String
prettyReport r =
printf
"Budget: %s, Net: %s, difference: %s"
(prettyMoney (budgetProfit r))
(prettyMoney (netProfit r))
(prettyMoney (difference r))
OK, we now have decimals. Let’s continue with the customer requirements.
A Polymorphic ProjectGroup Field
Our hypothetical customer wants reporting at the project group level as well. We extend the project data type with yet another type argument, and a polymorphic field for project groups, to store the new information.
data Project g a
= Project Text
a
| ProjectGroup Text
g
[Project g a]
deriving (Show, Eq, Functor, Foldable, Traversable)
The field g
will be the slot for project group level reports in our resulting data structure. It could hold any data, so if we wanted project groups to have IDs, this would be a place to put them. We use the type variable g
when constructing the recursive Project
type.
Reports for Project Groups
We will use the WriterT
monad transformer to collect child project reports as we recurse through the project data structure. The MonadWriter
type class is a multi-param type class, and thus we need to enable FlexibleContexts
.
{-# LANGUAGE FlexibleContexts #-}
liftIO
will be needed to lift an IO action into a WriterT
action, and we need some functions related to WriterT
.
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Writer (listen, runWriterT, tell)
We will not need the accumulateProjectReport
function anymore, as calculateProjectReports
will return reports on all levels.
calculateProjectReports
will accept a project tree with any group value g
, and return an IO
action of a project tree, with reports for group projects and invididual projects.
In this version of calculateProjectReports
, we will do recursion explicitly. Even if we would reach for something like Bifunctors or Bitraversables, we could not collect child reports in a traversal while retaining the project tree structure. Again, Traversable only transforms individual elements, and Foldable collapses the structure.
Instead, we will define calc
, a function that recurses through the project tree using the WriterT monad transformer, combined with IO
.
The Haskell Wiki describes the writer monad’s computation type as “computations which produce a stream of data in addition to the computed values” and says it’s “useful for logging, or computations that produce output on the side.” In our case, the output produced on the side is a report, and the computed value is a project.
We will start at the top project level, so we need that argument. The result of runWriterT
is a tuple of the return value of the computation, and the written report. As we have already included the relevant reports in our return value (the project structure, that is,) we extract only the first element of the tuple.
calculateProjectReports :: Project g ProjectId -> IO (Project Report Report)
calculateProjectReports project = fst <$> runWriterT (calc project)
where
-- (definitions below)
Given a single project, we will calculate a report, just as before. But in addition to including it in the resulting project value, we will also tell
it. This is an operation of the Writer
monad.
calc (Project name p) = do
report <-
liftIO (calculateReport <$> DB.getBudget p <*> DB.getTransactions p)
tell report
pure (Project name report)
Given a project group, we calculate report-decorated projects by mapping calc
over all sub-projects. Also, we use listen
to extract the combined report of all those sub-projects.
calc (ProjectGroup name _ projects) = do
(projects', report) <- listen (mapM calc projects)
pure (ProjectGroup name report projects')
With the writer monad, the type of value you tell
must have a Monoid
instance. All told values are appended, starting with the empty element, and thus we get a single report back.
At the project group level, we don’t have to tell
any report, as the individual projects under it have already done so, and as those reports accumulate in the writer monad.
Printing and Testing
In the PrettyPrint
module, we will not need the qualified import of Text
anymore.
The asTree
function will now need yet another pretty-printing function as an argument. It will be used to print group values. Like with prettyValue
, we need to pass it along when recursing.
asTree :: (g -> String) -> (a -> String) -> Project g a -> Tree String
asTree prettyGroup prettyValue project =
case project of
Project name x -> Node (printf "%s: %s" name (prettyValue x)) []
ProjectGroup name x projects ->
Node
(printf "%s: %s" name (prettyGroup x))
(map (asTree prettyGroup prettyValue) projects)
Again, the prettyProject
helper needs the same arguments.
prettyProject :: (g -> String) -> (a -> String) -> Project g a -> String
prettyProject prettyGroup prettyValue =
drawTree . asTree prettyGroup prettyValue
In our Demo
module test data, we don’t have any group values. We don’t have any interesting information to put there at the moment, so we will use unit values.
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
We can now calculate a project tree with reports on all levels, and pretty-print it, using prettyReport
for both groups and individual projects.
$ stack repl
...
> pr <- calculateProjectReports someProject
> putStrLn (prettyProject prettyReport prettyReport pr)
Reporting Database Demo PrettyPrint Project Reporting> pr <- calculateProjectReports someProject
*Reporting Database Demo PrettyPrint Project Reporting> putStrLn (prettyProject prettyReport pretty
prettyMoney prettyProject prettyReport
*Reporting Database Demo PrettyPrint Project Reporting> putStrLn (prettyProject prettyReport prettyReport pr)
Sweden: Budget: -6850.33, Net: -5592.98, difference: +1257.35
|
+- Stockholm: Budget: +2868.43, Net: -3065.94, difference: -5934.37
|
+- Gothenburg: Budget: +1938.65, Net: +2314.45, difference: +375.80
|
`- Malmö: Budget: -11657.41, Net: -4841.49, difference: +6815.92
|
+- Malmö City: Budget: -9428.67, Net: -2829.45, difference: +6599.22
|
`- Limhamn: Budget: -2228.74, Net: -2012.04, difference: +216.70
Very nice reporting, indeed!
Summary
And we’re done. We have extended our project management system to handle reporting at all project levels, using explicit recursion, and the WriterT
monad transformer to accumulate reports along the way.
In the next part, the last one of this series, we will accomplish the same goal in much more generic way, using the Fixplate
package.
Source Code
The source code for the full series is available at github.com/haskell-at-work/domain-modelling-with-haskell.