Monday, October 18, 2010

Domain Modeling in Haskell - Combinators for Composition

In my last post I had started sharing my explorations of the Haskell land in a simple domain modeling exercise. I find this a very fruitful way of learning a new language. Try something meaningful and don't feel shy to share with the community. I got some good feedback from Steven and immediately jumped on to improve the quality of the code.

In this post I will enhance and improve upon the earlier model. The focus will once again be on understanding the compositional capabilities that Haskell offers. Eric Evans in his book on domain driven design talks about supple design of the domain model. He mentions the qualities that make a design supple when "the client developer can flexibly use a minimal set of loosely coupled concepts to express a range of scenarios in the domain". I think the meat of this statement is composability. When we have a minimal set of well designed abstractions, we can make them compose in various ways to implement the functionalities that our domain needs to implement.

Combinators for business rules

Combinators are a great way to compose abstractions. If your language supports higher order functions you can compose pure functions to build up larger abstractions out of smaller ones. Concatenative languages offer the best of combinator based designs. With an applicative language Haskell is as good as it gets. I will try to enrich our earlier domain model with combinators that make domain logic explicit abstracting most of the accidental complexities off into the implementation layers.

Consider the function forTrade in my last post. It returns the list of tax/fee ids that need to be charged for the current trade. We had a stub implementation last time where it was returning the same set of tax/fee ids for every trade. Let's improve upon this and make the function return a different set of tax/fees depending upon the market of operation. Say we have a Market data type as ..

data Market = HongKong | Singapore | NewYork | Tokyo | Any deriving (Show, Eq)

where Any is a placeholder for a generic market. We will use it as a wild card in business rules to hold all rules applicable for any market. As for example taxFeeForMarket is an association list that keeps track of the tax/fee ids for every market. The one with Any in the market field specifies the generic tax/fees applicable for every market. For Singapore market we have a specialization of the rule and hence we have a separate entry for it. Let's see how we can implement a forTrade that gives us the appropriate set of tax/fee ids depending on the market where the trade is executed.

taxFeeForMarket = [(Any, [TradeTax, Commission]), (Singapore, [TradeTax, Commission, VAT])]

-- tax and fees applicable for a trade
forTrade :: Trade -> (Trade, Maybe [TaxFeeId])
forTrade trade =
  let list = lookup (market trade) taxFeeForMarket `mplus` lookup Any taxFeeForMarket
  in (trade, list)

We use a combinator mplus which is provided by the typeclass MonadPlus in Control.Monad ..

class Monad m => MonadPlus m where
  mzero :: m a
  mplus :: m a -> m a -> ma

mzero gives a zero definition for MonadPlus while mplus gives an additive semantics of the abstraction. It's also true that mzero and mplus of MonadPlus form a Monoid and there are some significant overlaps between the two definitions. But that's another story for another day. In our modeling exercise, we use the instance of MonadPlus for Maybe, which is defined as:

instance MonadPlus Maybe where
  mzero = Nothing
  Nothing `mplus` ys = ys
  xs `mplus` _  = xs

As you will see shortly how using mplus for Maybe nicely abstracts the business rule for tax/fee determination that we are modeling.

In the context of our tax/fee id determination logic, the combinator mplus fetches the set of tax/fees by a lookup from the specific to the generic pairs. It first looks up the entry, if any, for the specific market. If it finds one, it stops the search then and there and returns the list. Or else it goes into the second lookup and fetches the generic list. All the detailed logic of this path in which the lookup is made is encapsulated within the combinator mplus. Look how succinct the expression is, yet it reveals all the intentions that the business rule demands.

Implementing the Bounded Context

When we design a system we need to have the context map clearly defined. The primary model is the one which we are implementing. This model will collaborate with many other models or external systems. As a well-behaved modeler we need to have the interfaces well defined with all inter-module communications published beforehand.

The domain model will interact with external world from where it's going to get many of the data that it will process. Remember we defined the Trade data type in the last post. It was defined as a Haskell data type and was used all over our implementation. It is an implementation artifact which we need to localize within our impelmentation context only.

Trade data is going to come from external systems, may be over the Web as a list of key/value pairs. Hence we need to have an adaptor data structure, generic enough to supply our domain model the various fields of a security trade.

We use an association list - a list of key/value pairs for this. But how do we prepare a Trade data type from this list, without making the code base filled with boilerplatey stuff ?

Remember that a valid trade has to contain all of these fields. The moment we fail to get any of them, we need to mark the trade construction invalid and return a null trade. Null ? We know that the invention of nulls have been declared a billion dollar mistake by none other than the inventor himself.

Here we will use a monad in Haskell - the Maybe. Our constructor function will take an association list and return a Maybe Trade. Every lookup in the association list will return a Maybe String. We need to lift the String from it into the Trade data constructor. This is a monadic lift and we use two combinators liftM and ap for doing this. The moment one lookup fails, the function makeTrade returns a Nothing without proceeding with further lookups. All these details are encapsulated within these combinators, which make the following code expressive and without much of an accidental complexity.

makeTrade :: [(String, Maybe String)] -> Maybe Trade
makeTrade alist =
    Trade `liftM` lookup1 "account"                      alist
             `ap` lookup1 "instrument"                   alist
             `ap` (read `liftM` (lookup1 "market"        alist))
             `ap` lookup1 "ref_no"                       alist
             `ap` (read `liftM` (lookup1 "unit_price"    alist))
             `ap` (read `liftM` (lookup1 "quantity"      alist))

lookup1 key alist = case lookup key alist of
                      Just (Just s@(_:_)) -> Just s
                      _ -> Nothing

Now we have defined an external interface of how trades will be constructed with data coming from other modules. The domain model is thus protected through this insulation layer preventing our internal implementation from leaking out. As I mentioned earlier, the outer levels liftM and ap combinators lift data from the Maybe String that lookup1 returns into the Maybe Trade which the function makeTrade returns. Also note how we convert the String values for unit_price and quantity into the respective Double data types through the magic of typeclasses using read. Nowhere we mention that the values need to be converted to Double - it's done through type inference and the magic of automatically using the appropriate instance of the Read typeclass by the compiler.

Here are the other functions, some of them refactored from the version that we developed in the earlier post.

-- trade enrichment
enrichWith :: (Trade, [(TaxFeeId, Double)]) -> RichTrade
enrichWith (trade, taxfees) = 
    RichTrade trade $ M.fromList taxfees

-- tax fee valuation for the trade
taxFees :: (Trade, Maybe [TaxFeeId]) -> (Trade, [(TaxFeeId, Double)])
taxFees (trade, Just taxfeeids) =
    (trade, zip taxfeeids (map (valueAs trade) taxfeeids))
taxFees (trade, Nothing) =
    (trade, [])

-- calculation of each tax and fee
rates = [(TradeTax, 0.2), (Commission, 0.15), (VAT, 0.1)]
valueAs :: Trade -> TaxFeeId -> Double
valueAs trade taxFeeId = 
    (principal trade) * (fromMaybe 0 (lookup taxFeeId rates))

-- compute net amount
netAmount :: RichTrade -> NetAmount
netAmount rtrade = 
    let t = trade rtrade
        p = principal t
        m = taxFeeMap rtrade
    in M.fold (+) p m

= enrichWith . taxFees . forTrade

Our domain model is fleshing out gradually. We have even added some stuff to pull data into our model from external contexts. In future posts I will explore more how Haskell combinators can make your domain model expressive yet succinct. This exercise is turning out to be a great learning exercise for me. Feel free to suggest improvements that will make the model better and more idiomatic.

No comments: