Skip to content

Instantly share code, notes, and snippets.

@parsonsmatt
Created December 11, 2017 15:48
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save parsonsmatt/5ef59fa81a39680c5489959092a7dea3 to your computer and use it in GitHub Desktop.
Save parsonsmatt/5ef59fa81a39680c5489959092a7dea3 to your computer and use it in GitHub Desktop.
A basic draft of a future blog post

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:

Layer 1:

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.

Layer 2

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment