The question of "How do I design my application in Haskell?" comes up a lot. There's a bunch of perspectives and choices, so it makes sense that it's difficult to choose just one. Do I use plain monad transformers, mtl
, just pass the parameters manually and use IO
for everything, the ReaderT
design pattern, free monads, freer monads, some other kind of algebraic effect system?!
The answer is: why not both/all?
Lately, I've been centering on a n application design architecture with roughly three layers:
newtype AppT m a = AppT { unAppT :: ReaderT YourStuff m a } deriving ............
The ReaderT
Design Pattern, essentially. This is what everything gets boiled down to, and what everything eventually gets interpreted in. This type is the backbone of your app. For some components, you carry around some info/state (consider MonadMetrics
or katip
's logging state/data); for others, you can carry an explicit effect interpreter. This layer is for defining how the upper layers work, and for handling operational concerns like performance, concurrency, etc.
This layer sucks to test. So don't. Shift all the business logic up into the next two layers as much as possible. You want this layer to be tiny.
mtl
style classes, implemented in terms of domain resources or effects. eg: class MonadTime m where getCurrentTime :: m UTCTime
, or class MonadLock m
that contains logic around acquiring distributed locks; in test I use an IORef (Map ByteString ByteString)
and in prod I use redis
. Or class AcquireModel m where ...
that represents a means of acquiring some data (this can be behind a database, HTTP, etc. and you should be able to easily swap backends). These are higher level than AppT IO
and delimit the effects you use; but are ultimately lower level than real business logic. You might see some MonadIO
in this layer, but it should be avoided where possible. This layer should be expanded on an as-needed (or as-convenient) basis. As an example, implementing MonadLock
as a class instead of directly in AppT
was done because using Redis directly would require that every development and test environment would need a full Redis connection information. That is wasteful so we avoid it. Implementing AcquireModel
as a class allows you to omit database calls in testing, and if you're real careful, you can isolate the database tests well.
DO NOT try to implement MonadRedis
or MonadDatabase
or MonadFilesystem
here. That is a fool's errand. Instead, capture the tiny bits of your domain: MonadLock
, MonadModel
, or MonadSpecificDataAcquisition
. The smaller your domain, the easier it is to write mocks and tests for it. You don't want to try and write a SQL database, so don't -- capture the queries you need as methods on the class so they can easily be mocked. Alternatively, present a tiny query DSL that is easy to write an interpreter for.
However, this technique is pretty heavy-weight: this layer should be as thin as possible, preferring to instead push stuff into the Layer 3.
Business logic. This should be entirely pure, with no IO
component at all. This should almost always just be pure functions. All the effectful stuff should have been captured beforehand, and all effectful post-processing should be handled afterwards. As an example, suppose you have a function streamFromFile :: FilePath -> Producer IO (Either ParseError MyValue)
. Factor the IO out, and you get a function streamingParse :: Monad m => Conduit ByteString m (Either ParseError MyValue)
which is essentially pure and can be tested trivially; you just use sourceList listOfBytestrings .| streamingParse
.
Free monads are a technique to encode computation as data, but they're very complicated; you can usually get away with a much simpler datatype to express the behavior you want. A simple non-recursive query DSL works just as well for many cases, free applicatives (aka linked lists of commands) have nice optimization/analysis properties that you lose on upgrading to free monads.