Domain Driven Design using GADTs

September 12, 2022

This short post is part of the Practical Haskell Bits initiative. Visit the repository to find out more real-world examples like this.

Let’s imagine we’re in the context of an online store.

We have an Order type that has two possible states - Outstanding and PaidFor.

data OrderStatus = Outstanding | PaidFor

data ShipmentInfo =
  AwaitingShipment |
  Shipped TrackingNumber ShippedAt

data Order = Order
  { id :: OrderId,
    created :: CreatedAt,
    items :: [OrderItem],
    status :: OrderStatus,
    shipmentInfo :: Maybe ShipmentInfo
  }

While writing our business logic, we realize that we’re often doing error prone validations.

refundOrder :: Order -> m ()
refundOrder order = do
  when (status order == PaidFor) $
    error "This order has already been paid for."
  ...

markAsShipped :: Order -> m ()
markAsShipped order = do
  unless (status order == PaidFor) $
    error "Order must be paid for."

  unless (shipmentInfo order == Just AwaitingShipment) $
    error "Order must be awaiting shipment."

This would pass the bar in most languages, but it feels like we may be underutilizing Haskell’s type system, so we want to refactor. There’s multiple approaches that we could take to make this situation better, but today we’ll take a look at GADTs (Generalized Algebraic Data Types).

Using GADTs, first we can move the OrderStatus to the type level.

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}

-- Rename Order to OrderData and remove the status and shipmentInfo
data OrderData = OrderData
  {
    -- status :: OrderStatus,
    -- shipmentInfo :: Maybe ShipmentInfo
  }

-- Define a new `Order` type that's a lot more type safe
data Order (status :: OrderStatus) where
  OutstandingOrder :: OrderData -> Order 'Outstanding
  PaidOrder :: OrderData -> ShipmentInfo -> Order 'PaidFor

This allows us to explictly mark the order type we want to work with

markAsPaid :: Order 'Outstanding -> m ()
markAsPaid = ...

markAsShipped :: Order 'PaidFor -> m ()
markAsShipped = ...

refundOrder :: Order 'PaidFor -> m ()
refundOrder = ...

-- Or we can just ignore the type if we don't care about it
data SomeOrder = forall status. SomeOrder (Order status)

getAllOrders :: m [SomeOrder]
getAllOrders = ...

It also allows us to drastically reduce the validations needed. In fact, the previously “illegal states” are now unrepresentable.

refundOrder :: Order 'PaidFor -> m ()
refundOrder order = do
  -- We don't need to validate the status as
  -- it cannot be anything different than `PaidFor`
  ...

markAsShipped :: Order 'PaidFor -> m ()
markAsShipped (PaidOrder orderData shipmentInfo) = do
  -- We've stated in the type signature that
  -- `markAsShipped` works with orders that are PaidFor.
  -- Since `ShipmentInfo` on `PaidOrder` is no longer a `Maybe`,
  -- we don't need to validate anything.
  ...

We used this approach in aws-lambda-haskell-runtime. Since Lambda results and errors must have a differently formatted body depending on the proxy (API Gateway, ALB, etc.), we used GADTs to make illegal states unrepresentable.

Find the complete code example here.

This short post is part of the Practical Haskell Bits initiative. Visit the repository to find out more real-world examples like this.


Profile picture

Written by Dobromir Nikolov , who's still wondering why people pay him to make software. You should check them out on GitHub

© 2022, Built with ❤️ and Gatsby