Migrating code to the Reader Monad

The Reader Monad is one of the famous trio: Reader/Writer/State. They are all really powerful abstractions and fit specific scenarios like being able to read global configuration, being able to trace the execution of the program or being able to run stateful computations.

In this post, I will share how to evolve a program using explicit configuration parameter into a program that uses the Reader Monad.

The base program

Imagine we wrote a Haskell program which:

  1. Establishes a connection to the memcache server using the memcache package
  2. Reads and prints the current value for the counter key
  3. Stores "1" under the counter key
  4. Reads and prints the current value for the above key
  5. Stores "2" under the counter key
  6. Reads and prints the current value for the above key

Here are the necessary imports:

{-# LANGUAGE OverloadedStrings #-}
module Main where

import qualified Database.Memcache.Client as MemcacheClient
import qualified Database.Memcache.Types as MemcacheTypes
import           Data.Maybe (Maybe(..))

A type alias for memcache result:

type MemcacheResult = Maybe (MemcacheTypes.Value, MemcacheTypes.Flags, MemcacheTypes.Version)

A set of methods to establish the connection, store values in and retrieve from the server:

connection :: IO MemcacheClient.Client
connection = MemcacheClient.newClient [MemcacheClient.def] MemcacheClient.def

-- Example: set connection "counter" "1"
set :: MemcacheClient.Client -> MemcacheTypes.Key -> MemcacheTypes.Value -> IO MemcacheTypes.Version
set connection key value = MemcacheClient.set connection key value 0 0

-- Example: get connection "counter"
get :: MemcacheClient.Client -> MemcacheTypes.Key -> IO MemcacheResult
get = MemcacheClient.get

And finally - the actual program:

main :: IO ()
main = do
  conn <- connection

  get conn "counter" >>= print
  set conn "counter" "1"
  get conn "counter" >>= print
  set conn "counter" "2"
  get conn "counter" >>= print

  return ()
  
-- First run:
-- Nothing
-- Just ("1",0,1)
-- Just ("2",0,2)
--
-- Consequent run:
-- Just ("2",0,2)
-- Just ("1",0,3)
-- Just ("2",0,4)

The transition to the Reader Monad

After installing the mtl package we can make use of the Reader Monad to make the above functions a bit easier to use.

We start by adding some additional imports:

import Data.Function (flip)
import Control.Monad.Reader (ReaderT, runReaderT, ask)
import Control.Monad.IO.Class (liftIO)

Next, we change the get and set helper functions:

-- Example: set "counter" "1"
set :: MemcacheTypes.Key -> MemcacheTypes.Value -> ReaderT MemcacheClient.Client IO MemcacheTypes.Version
set key value = do
  connection <- ask
  liftIO $ MemcacheClient.set connection key value 0 0
  
-- Example: get "counter"
get :: MemcacheTypes.Key -> ReaderT MemcacheClient.Client IO MemcacheResult
get key = do
  connection <- ask
  liftIO $ MemcacheClient.get connection key

and finally we change the main function to call runReaderT with the connection used as the configuration/environment being read with ask calls:

main :: IO ()
main = do
  conn <- connection

  flip runReaderT conn $ do
    get "counter" >>= liftIO . print
    set "counter" "1"
    get "counter" >>= liftIO . print
    set "counter" "2"
    get "counter" >>= liftIO . print

  return ()

-- First run:
-- Nothing
-- Just ("1",0,1)
-- Just ("2",0,2)
--
-- Consequent run:
-- Just ("2",0,2)
-- Just ("1",0,3)
-- Just ("2",0,4)

Full code listing: src/Main.hs

Summary

With these small changes, we managed to transform the base program to pass connection to helper functions from one place thanks to how the reader monad works. The actual behavior of the program didn't change.

In the real world, one would pass a record full of configuration options — database host, api endpoints, environment type etc. — to the runReaderT call.

Do you find the second version better or do you think it's worse? Let me know on twitter - @maciejsmolinski.