2023-01-30
I have been learning and playing with Haskell on-and-off for a couple of years now. However, I was still not very confident that I could write a real-world application in it. So, I decided to give it another shot. My criteria for a “real-world” application is -
The patterns I describe in this post are inspired from The ReaderT Design Pattern and Three Layer Haskell Cake articles.
Dependency injection is a well known pattern in Object Oriented programming. The idea is to “program to interfaces” and inject implementations of interfaces during initialization. This pattern is very well understood and we will be implementing this pattern in Haskell using Tagless-Final and ReaderT patterns.
Tagless-Final pattern suggests programming to typeclass constraints instead of concrete types. It is basically the Haskell version of programming to interfaces.
Say we want to write a function to download a wallpaper from the Internet and save it to local disk.
import qualified Data.ByteString as BS
import qualified Network.HTTP.Simple as HTTP
import System.FilePath ((</>))
type URL = String
type WallpaperName = String
downloadWallpaper :: FilePath -> WallpaperName -> URL -> IO ()
= do
downloadWallpaper dir name url <- HTTP.httpBS (HTTP.parseRequest_ url) -- get the wallpaper
wallpaper let path = dir </> name
-- save the wallpaper BS.writeFile path (HTTP.getResponseBody wallpaper)
This function is tightly coupled to httpBS
and
writeFile
. If, instead of saving the wallpaper to the disk,
we wanted to save it to a database, then we would need to write a new
function entirely.
With the Tagless-Final pattern we can make this function more flexible. The idea is to write typeclasses that export actions and program high-level functions to the methods of the typeclasses.
{-# LANGUAGE FlexibleInstances #-}
import Data.ByteString (ByteString)
type URL = String
type WallpaperName = String
downloadWallpaper ::
MonadGetWallpaper m, -- Provides a method to get the wallpaper
( MonadSaveWallpaper m -- Provides a method to save the wallpaper
=>
) WallpaperName ->
URL ->
m ()= do
downloadWallpaper name url <- getWallpaper url -- get the wallpaper
wallpaper -- save the wallpaper
saveWallpaper name wallpaper
class Monad m => MonadGetWallpaper m where
getWallpaper :: URL -> m ByteString
class Monad m => MonadSaveWallpaper m where
saveWallpaper :: WallpaperName -> ByteString -> m ()
Now we can provide different instances for the typeclasses to get
different effects from the same function. Note that the new
downloadWallpaper
function no longer has a directory
parameter. It is now more high-level and is agnostic to low-level
details of wallpaper saving.
Note that a single type is required to implement all typeclasses we
introduce with the Tagless-Final pattern. With the ReaderT pattern, the
type we choose to implement the typeclasses is
ReaderT Env IO
. Env
will usually be a record
type containing all configuration, state, and resources needed by our
application.
For our running example, we’d need a directory to save the
wallpapers. Let’s create a record type Env
that will
contain a wallpaper directory as configuration and make
MonadIO m => ReaderT Env m
implement the two
typeclasses.
data Env = Env
wallpaperDir :: FilePath
{
}
instance MonadIO m => MonadSaveWallpaper (ReaderT Env m) where
= do
saveWallpaper name wallpaper <- asks wallpaperDir
dir $ saveWallpaperToDisk dir name wallpaper
liftIO
instance MonadIO m => MonadGetWallpaper (ReaderT Env m) where
= liftIO . getWallpaperFromURL
getWallpaper
saveWallpaperToDisk :: FilePath -> WallpaperName -> ByteString -> IO ()
= BS.writeFile (dir </> name)
saveWallpaperToDisk dir name
getWallpaperFromURL :: URL -> IO ByteString
=
getWallpaperFromURL fmap HTTP.getResponseBody . HTTP.httpBS . HTTP.parseRequest_
Now we can run the donwloadWallpaper
function as
follows.
runEnv :: IO ()
=
runEnv
runReaderT
( downloadWallpaper"wallhaven-kx36mq.png"
"w.wallhaven.cc/full/kx/wallhaven-kx36mq.png"
)Env "wallpapers") (
This allows us to write unit tests for the
downloadWallpaper
function using a test environment that,
instead of making actual HTTP calls and saving data to the disk, would
return fake wallpaper data from memory and save wallpapers to an
IORef
variable.
data TestEnv = TestEnv
wallpaperData :: ByteString,
{ savedWallpapers :: IORef [(WallpaperName, ByteString)]
}
instance Monad m => MonadGetWallpaper (ReaderT TestEnv m) where
= asks wallpaperData
getWallpaper _
instance (Monad m, MonadIO m) => MonadSaveWallpaper (ReaderT TestEnv m) where
= do
saveWallpaper name wallpaper <- asks savedWallpapers
wallpapers $ modifyIORef wallpapers ((name, wallpaper) :) liftIO
A simple test would then look like below.
import Test.Hspec (describe, hspec, it, shouldBe)
runTest :: IO ()
= hspec $ do
runTest "downloadWallpaper" $ do
describe "saves the wallpaper to disk" $ do
it let name = "abc.jpg"
= "wallpaper data"
wallpaperData = "wallpaper.com/abc.jpg"
url
<- newIORef []
savedWallpapersIORef let env = TestEnv wallpaperData savedWallpapersIORef
runReaderT (downloadWallpaper name url) env
<- readIORef savedWallpapersIORef
savedWallpapers `shouldBe` [(name, wallpaperData)] savedWallpapers
High-level application functions might also need access to
configuration or state data. A Has typeclass is a pattern that allows
extracting out some value from application’s environment. Has
typeclasses export a single method that returns the desired value. For
example, if our application has a debug mode flag then we could write a
HasDebugMode
typeclass that exports a method to get the
debug mode value.
class HasDebugMode env where
debugMode :: env -> Bool
Note that Has
typeclasses are to be implemented by the
environment type Env
and not by ReaderT Env m
monad.
data Env = Env
wallpaperDir :: FilePath,
{ envDebugMode :: Bool
}
instance HasDebugMode Env where
= envDebugMode debugMode
Functions that need access to the debug mode flag should add the
HasDebugMode env
constraint. For example, if we want to add
some debug logs to our downloadWallpaper
function then we
can add MonadReader env m
and HasDebugMode env
constraints to it and then have access to the debug mode using
asks debugMode
as shown below.
downloadWallpaper ::
MonadReader env m,
( HasDebugMode env,
MonadGetWallpaper m,
MonadSaveWallpaper m,
MonadIO m -- for priting debug logs
=>
) WallpaperName ->
URL ->
m ()= do
downloadWallpaper name url <- asks debugMode
debug $ liftIO $ putStrLn "Downloading wallpaper..."
when debug <- getWallpaper url
wallpaper saveWallpaper name wallpaper
So, the idea is to use Tagless-Final and ReaderT patterns together. These two patterns help us structure our applications in a modular fashion. High-level and low-level details of the application are well separated and loosely coupled through the typeclasses.
Another pattern that I found useful is to structure applications with three layers aka the three-layer cake. Layer-1 depends on Layer-2 which in-turn depends on Layer-3.
This layer is concerned about the high-level flow of the application.
All functions in this layer are high-level and program to typeclasses.
If the functions need access to specific configuration data then a
Has
typeclass should be added as a constraint. If the
functions need a specific side-effect such as downloading some data then
an appropriate typeclass constraint that exports the side-effect should
be added to it. In our example from above, the
downloadWallpaper
function belongs to Layer-1.
Application environment is a part of Layer 1 and any functions in
this layer that need access to environment values such as application
configuration would need MonadReader env m
and
Has MyConfiguration env
constraints.
As we saw before, the environment type, say Env
, is a
record that contains all configuration, resources, and state needed to
run the application. It implements all the Has
typeclasses
and ReaderT Env m
monad implements all typeclasses that
expose methods with side-effects. For tests, we could write a separate
test environment type, say TestEnv
, that impelements mock
functionality for the typeclasses that expose methods with side-effects.
If we want to swap some implementation for our application, say save
data to a database instead of disk, we could write a new environment
type and implement the typeclasses as suitable for the new
environment.
The second layer is composed of the various typeclasses that Layer-1
functions program to. Each typeclass exposes a single piece of monadic
functionality. The MonadGetWallpaper
and
MonadSaveWallpaper
typeclasses from our example belong to
Layer 2.
The third layer is composed of library functions that are used by
environments to implement the Layer-2 typeclasses. These functions are
either pure or have a simple typeclass constraint such as
MonadIO
. saveWallpaperToDisk
and
getWallpaperFromURL
functions from our example belong to
Layer 3. These functions take everything they need as explicit
parameters and there is no magic involved. Any long-lived resources such
as file handles or database connections will be taken as parameters by
Layer-3 functions.
Now let’s tackle the unpleasant yet necessary part of any real-world application - error handling. A real-world application must account for actions that can go wrong. A service the application is calling might be down or the disk might be full or there could be permission issues when performing an action.
First, I recommend throwing exceptions in real-world Haskell
applications when an IO action fails. When I was new to Haskell I saw
beautiful monads such as Either
and Expect
that seemed to make all error handling explicit but also elegant. I
naively believed that I wouldn’t have to deal with runtime exceptions
ever and that Haskell is the best language. I do agree with the latter
part, however, later I realized that Haskell does not have any magic for
dealing with runtime exceptions and they are a fact of life in the
Haskell world. See this
post from FP Complete that explains why throwing exceptions is
better than attempting to capture all possible exceptional cases in
function types.
saveWallpaperToDisk
Layer-3 function in our example can
fail with an exception due to a lack of write permissions to the
directory. In that case, there is nothing that this function can do and
must let the exception propogate upwards. From Layer 1’s perspective,
the wallpaper failed to save, it is not concerened with the cause that
is the lack of permissions, it is not even aware that the wallpaper is
being saved to a file. To catch exceptions at Layer 1, let’s create a
sum type that captures everything that can go wrong. Typically, you will
have one exception data constructor per monadic action in your Layer-2
typeclasses.
data AppException
= WallpaperGetException String
| WallpaperSaveException String
deriving (Show, Typeable)
instance Exception AppException
Here we have defined a sum type AppException
that will
capture all known exceptions for our application. Our download wallpaper
application can fail due to two reasons - wallpaper failing to be
fetched from the URL and wallpaper failiing to save. Each type of
exception will have an underlying cause that depends on the instances of
our Layer-2 typeclasses being used for evaluation. Since Layer 1 is
agnostic to the instances of the typeclasses, we are demanding that the
instances encode the underlying errors in the String
type.
Since Layer 1 and Layer 3 known exceptions are of different types, Layer 2 needs to perform a mapping from Layer 3’s exception types to Layer 1’s exception type.
instance MonadIO m => MonadSaveWallpaper (ReaderT Env m) where
= do
saveWallpaper name wallpaper <- asks wallpaperDir
dir -- use the new function
saveWallpaperToDiskExcept dir name wallpaper
saveWallpaperToDiskExcept ::
MonadIO m => FilePath -> WallpaperName -> ByteString -> m ()
=
saveWallpaperToDiskExcept dir name wallpaper $
liftIO catch
(saveWallpaperToDisk dir name wallpaper). handleIOError dir) -- map the IO exception to AppException
(throwIO
handleIOError :: FilePath -> IOError -> AppException
handleIOError dir e| isPermissionError e =
WallpaperSaveException $ "no permission to save wallpaper to " <> show dir
| otherwise = WallpaperSaveException $ show e
In the above example, we are mapping all IOError
exceptions to AppException
s. A user-friendly error message
is generated for permissions issues and the default error messages are
used for all other cases.
We can now handle AppException
s in Layer 1.
runEnvExcept :: IO ()
=
runEnvExcept catch runEnv handleException
handleException :: AppException -> IO ()
WallpaperGetException msg) =
handleException (putStrLn $ "Failed to get wallpaper: " <> msg
WallpaperSaveException msg) =
handleException (putStrLn $ "Failed to save wallpaper: " <> msg
runEnv :: IO ()
=
runEnv
runReaderT
( downloadWallpaper"wallhaven-kx36mq.png"
"http://w.wallhaven.cc/full/kx/wallhaven-kx36mq.png"
)Env "/" True) -- no permission to save to "/" (
By running runEnvExcept
in cabal repl
we
see that the permission error is handled gracefully by the
application.
λ> runEnvExcept
Downloading wallpaper...
Failed to save wallpaper: no permission to save wallpaper to "/"
Exceptions are unavoidable for impure code and it’s better to embrace
them than fight them. However, for pure code, I advise using
Either
or Except
monads or a
MonadThrow
instance.
When propogating a Layer-3 pure error from an impure Layer-2 function to a Layer-1 function, however, you are probably better off mapping the pure error to a Layer-1 exception and throwing it. This is because the impure Layer-2 function would already have exception throwing in its contract, so having a single way (throwing exceptions) of signaling failure is better than having two ways.
That is all I have for this post. The application that inspired this post is wallhaven-sync that is a CLI for syncing wallpapers from Wallhaven website to my computer. Check it out! Tagless-Final, ReaderT, Has typeclasses, and embracing exceptions have all helped me write a Haskell application that I am somewhat satisfied with. In the future I plan to explore Free monads to achieve similar or better results!