After more than two years of writing production code using Haskell at Klarna. We’ve learned a ton. Initially, we used a ReaderT
pattern detailed in my “Haskell in Production” mini-series. We’ve now transitioned into using MonadTrans
and MonadTransControl
as means to write MTL without boilerplate.
In this post, we’re going to review the ReaderT
pattern we used, as well as go through its shortcomings and our chosen solution to it. Since a lot of people might only be interested in the solution, it is provided first.
Each interface has a corresponding class
:
class Monad m => MonadLog m where
-- | Print 'a' to the log with source code positions
logLn :: (HasCallStack, Loggable a) => LogLevel -> a -> m ()
which acts as the interface and allows us to write code that’s polymorphic in m
.
There’s a pass-through instance that is able to take any transformer monad t
that has an instance for MonadLog
on its base monad m
:
-- Pass-through instance for transformers
instance {-# OVERLAPPABLE #-}
Monad (t m)
( MonadTrans t
, MonadLog m
, => MonadLog (t m) where
) = lift (logLn level msg) logLn level msg
Having the OVERLAPPABLE
pragma on the pass-through instance means that any other instance we define would be chosen in preference to this one during instance resolution. This is described in the GHC user’s guide.
The instance above is used in order to provide instances for any transformer. Getting us past the dreaded n^2
issue! If you don’t know what that is - don’t worry, it’s explained further down.
Now it comes time to choose how to implement our effects. For each effect, there’s a newtype that constitutes the effect:
-- | Newtype for disabling logging
newtype NoLoggingT m a
= NoLoggingT { runNoLoggingT :: m a }
deriving newtype (Functor, Applicative, Monad)
deriving (MonadTrans) via IdentityT
instance Monad m => MonadLog (NoLoggingT m) where logLn _ _ = pure ()
This instance allows for us to choose not to log when we run our final program using the runNoLoggingT
function provided as a field in the newtype.
Here’s a real implementation of a console logger using fast-logger:
-- Transformer for logging to Console
newtype ConsoleLogT m a
= ConsoleLogT { unConsoleLogT :: ReaderT (LoggerSet, Trace) m a }
deriving newtype (Functor, Applicative, Monad, MonadTrans)
-- Instance using fast-logger to print to console
instance MonadIO m => MonadLog (ConsoleLogT m) where
= ConsoleLogT do
logLn level msg <- ask
(loggerSet, traceId) ConsoleLogger loggerSet) level (toLogItem msg)
logItem traceId (
runConsoleLogT :: MonadIO m => ConsoleLogT m a -> m a
ConsoleLogT m) = do
runConsoleLogT (<- liftIO (newStdoutLoggerSet defaultBufSize)
loggerSet Uncorrelated) runReaderT m (loggerSet,
Thanks to this structure, we can dispatch our effects at the “end of the world” by using the functions from each interface we want to use. The instances we’ve defined for ConsoleLogT
and NoLoggingT
are considered before the pass-through instance due to the OVERLAPPABLE
pragma.
Here’s an example from a service that logs things, submits metrics and reads messages from an SQS queue.
runProgram :: MetricsReporter -> QueueUrl -> AWS.Env -> IO ()
=
runProgram reporter queueUrl awsEnv = runConsoleLogT
. runMetricsT reporter
. runConsumerT queueUrl awsEnv
$ program
program :: (SqsConsumer m, MonadLog m, MonadMetrics m) => m ()
= ... program
This reminds us of how effects are dispatched using free monads, but by relying on something that’s well optimized by GHC and has strong support (MTL) in the community.
The rest of this post discusses how we got here and what we did before.
When starting out with Haskell, we didn’t want to overcomplicate things. Reader
is one of the first monads that our people got comfortable with.
Thus, our interfaces were all based on ReaderT
instances. This meant that essentially all interfaces required some data r
and were then based on MonadIO m
like:
-- | An interface to produce SQS messages
class Monad m => SqsProducer m where
-- | Produce messages to SQS, returning unit or an error in 'm'
produceMessage ::
SqsMessageGroupId -> SqsDedupeId -> SqsMessage -> m (Either SqsProducerError ())
data RequestSender m = RequestSender
_produceMessage ::
{SqsMessageGroupId -> SqsDedupeId -> SqsMessage -> m (Either SqsProducerError ())
}
instance
HasType (RequestSender m) r
( MonadCatch m
, MonadIO m
, MonadLog (ReaderT r m)
, => SqsProducer (ReaderT r m)
) = do
produceMessage groupId dedupeId message RequestSender produceMsg) <- asks getTyped
( lift (produceMsg groupid dedupeId message)
This gives us an end-of-the-world behavior where we need to do something similar to:
data ListenerContext m = ListenerContext
requestSender :: RequestSender m
{-- .. and other deps
}deriving stock (Generic)
runListener_ :: (MonadIO m) => ListenerContext -> m ()
= forever . runReaderT (void handleMessage)
runListener_
handleMessage :: SqsConsumer m => SqsProducer m => m Result
= do
handleMessage <- getMessage -- * Take a message from one queue
msg <- performAction msg -- * Perform some action
res -- * Put it on a different queue
produceMessage msg pure res -- * Return result of processing
in order to run our program. This is fine.
We’ve achieved what we want out of dependency injection:
RequestSender
data type. E.g. allowing us to stub it in testsThere are a few drawbacks to this pattern. Let’s start with the most glaring issues:
Granular control over interfaces becomes tedious due to the extra indirection with the passed data
Certain things are difficult to implement, e.g:
class Foo m where
withCallback :: (a -> m b) -> m b
This requires a lot of lifting back and forth especially when the concrete implementation is in IO
and your interfaces are all in m
Error messages become vague and based on the instance constraints e.g:
Couldn't satisfy constraint 'HasType (RequestSender m)'
instead of the much more easily understandable:
Missing instance 'SqsProducer (ReaderT r m)'
In the latter, we can see that the instance is missing for the SqsProducer
whereas in the former - we sort of need to do instance resolution by grep to figure out what class GHC is trying to construct an instance for.
Lastly, and most important: it didn’t turn out to be so easy to grok as we thought
There are a couple of alternatives to this approach to dealing with effects. If you want effect tracking in your types - there are a number of libraries that deal with this:
Both of these are promising, but we’re not really comfortable with the drawbacks to either one at the moment. In a nutshell - they’re both great libraries, however, they’re pretty advanced.
Our old solution combined two things - and this was its main mistake. Either we should’ve said no to our interfaces and gone with something like the handler pattern - or we should’ve leaned fully into MTL.
The drawback with handler pattern is that we can’t be polymporphic, which we really like for testing. The drawback with MTL is the n^2
instances problem.
Urgh! From our wishes on polymorphism it’s clear we can’t use the handler pattern. But can we use MTL if we solve the n^2
issue? And what is the n^2
issue?
n^2
issueWhen using monad transformers, you need to write the monad instances for all the different types of transformers. Here’s an example from the MTL source code:
instance MonadState s m => MonadState s (ExceptT e m) where
= lift get
get = lift . put
put = lift . state
state
instance MonadState s m => MonadState s (IdentityT m) where
= lift get
get = lift . put
put = lift . state
state
instance MonadState s m => MonadState s (ReaderT r m) where
= lift get
get = lift . put
put = lift . state state
This is very mechanical and boilerplaty. For these common transformers, these instances have all been written. However, every time you add an additional transformer - you need to write all these n
instances where n
is the number of interfaces you intend to use. Thus the n^2
complexity.
For most of our monads that we create ourselves, they simply require this very mechanical boilerplate. This behavior looks like it could be captured by a typeclass (or two).
One of our engineers, Moisés, who previously worked for Standard Chartered introduced us to their solution to this issue.
Pepe Iborra commented on the PR for this post and provided the following insight into how the solution came about:
When I joined Strats in Jan 2017, the codebase was already making heavy use of type classes for individual effects, e.g.
MonadTime
,MonadDelay
,MonadLog
, etc. but there was no solution to the n^2 problem. Monad transformers were providing instances for all the effects, relying on deriving to avoid as much boilerplate as possible. Alexis article takes this approach to the extreme.I made the point that introducing a new effect class required adding it to the deriving lists of all N transformers, which made engineers unwilling to add effects. and the approach could not scale. My solution to this was the passthrough instance, which requires a
MonadTransControl
transformer (orMonadtTrans
for non-scoped effects). Since allReaderT
transformers are inMonadTransControl
by definition unless the environment mentions the base monad, the codebase quickly gravitates towardsReaderT
in order to avoid having to write instances manually.
So, indeed, this can be captured by a typeclass. Enter MonadTrans
:
-- Pass-through instance for transformers
instance {-# OVERLAPPABLE #-}
Monad (t m)
( MonadTrans t
, MonadLog m
, => MonadLog (t m) where
) = lift (logLn level msg) logLn level msg
This instance can now lift any monad m
that implements MonadLog
into the transformer t
. This means no more having to write n
instances 🎉
As noted above, the OVERLAPPABLE
pragma allows us to control precedence for the pass-through instance, such that any other instance we define would be chosen in preference to it during instance resolution. This is described in the GHC user’s guide.
For the example above with a callback in m
, we can use MonadTransControl
as it has the ability to run something in the base monad. The real version of our MonadLog
has a function that allows you to specify a traceable ID that we call CorrelationId
:
class Monad m => MonadLog m where
-- | Print 'a' to the log with source code positions
logLn :: (HasCallStack, Loggable a) => LogLevel -> a -> m ()
-- | Correlate the 'm a' with the given correlation ID
correlatedWith :: CorrelationId -> m a -> m a
In our passthrough instance for this version of MonadLog
we now need to use MonadTransControl
:
-- Pass-through instance for transformers
instance {-# OVERLAPPABLE #-}
Monad (t m)
( MonadTransControl t
, MonadLog m
, => MonadLog (t m) where
) = lift (logLn level msg)
logLn level msg = do
correlatedWith corrId ma <- liftWith \runInBase ->
result
correlatedWith corrId (runInBase ma)pure result) restoreT (
We can leave out MonadTrans
since it’s implied by MonadTransControl
.
A full example was given in at the start of this post.
I hope this post presents a useful and comprehensible way to control effects in Haskell without deviating too much from standard language features.
Since writing this, I was pointed to Alexis’s article on making MTL typeclasses derivable. It’s a much more thorough article than mine and I greatly appreciated it.
OVERLAPPABLE
and their precedence in instance resolution (Moisés)n^2
issue when from where it was first mentioned (Moisés)ReaderT
section (Moisés)