Managing Effects
IO works when your program has one effect: "the real world". Real applications have several. You have configuration that needs to be read everywhere. You have a database connection pool. You have a logger. You have request-scoped state in a web handler. You have errors that aren't exceptions. You probably have metrics, tracing, and a feature-flag client. Every one of these is, conceptually, an effect — something your function depends on or produces beyond its arguments and return value.
How you organise these effects determines whether your codebase reads well or becomes a tangle of IO-laden helper functions. Haskell has a long, loud history of opinions on this. There are at least four real options in 2026, each with different tradeoffs. This page surveys them with practical guidance about when each makes sense.
The problem, concretely
Here is a function as you might first write it:
createOrder :: Connection -> Logger -> Config -> UserId -> [Item] -> IO OrderId
createOrder conn logger cfg uid items = do
logInfo logger ("creating order for " <> show uid)
let limit = orderLimit cfg
if length items > limit
then do
logWarn logger "too many items"
throwIO TooManyItems
else do
orderId <- insertOrder conn uid items
logInfo logger ("order " <> show orderId)
pure orderId
This is fine for a small program. In a 50-file codebase, it is not. Every function takes conn, logger, and cfg. Adding a new effect — say, metrics — means changing the signature of half your functions. Tests need to construct or mock conn and logger. The signal-to-noise ratio of the actual logic drops.
The shared question across all the patterns below: how do you represent the capability "this function can log" or "this function can read configuration" without threading it as an explicit argument?
Option 1: ReaderT pattern (Snoyman)
Michael Snoyman wrote a widely-cited blog post called "The ReaderT Design Pattern". The recipe:
- Define an
Appnewtype that wrapsReaderT Env IO. - Put everything you need (config, connection pool, logger) into
Env. - Use typeclasses to expose specific capabilities so functions only depend on what they use.
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE FlexibleInstances #-}
import Control.Monad.Reader
import Data.Pool (Pool)
import Database.Persist.Sql (SqlBackend)
data Env = Env
{ envPool :: Pool SqlBackend
, envConfig :: Config
, envLogger :: Logger
}
newtype App a = App { unApp :: ReaderT Env IO a }
deriving (Functor, Applicative, Monad, MonadIO, MonadReader Env)
class Monad m => HasLogger m where
logInfo :: Text -> m ()
logWarn :: Text -> m ()
instance HasLogger App where
logInfo msg = do
l <- asks envLogger
liftIO (writeLog l Info msg)
logWarn msg = do
l <- asks envLogger
liftIO (writeLog l Warn msg)
Now your business logic gets clean signatures:
createOrder :: (HasLogger m, HasDatabase m, HasConfig m)
=> UserId -> [Item] -> m OrderId
createOrder uid items = do
logInfo ("creating order for " <> tshow uid)
cfg <- getConfig
if length items > orderLimit cfg
then logWarn "too many items" >> throwIO TooManyItems
else do
orderId <- runDb (insertOrder uid items)
logInfo ("order " <> tshow orderId)
pure orderId
In tests, you create a different monad with the same typeclass instances — say, one that logs to a list and uses an in-memory store.
This pattern is what Mercury uses (and has written about publicly). It is also widespread in Yesod-style applications. The strength: it is simple, performant, and uses only language features anyone with a year of Haskell experience knows. The weakness: every "effect" is just a typeclass over IO, so anything you put in Env is real IO underneath, and you cannot easily reorder, intercept, or test pure subsets.
Option 2: MTL-style monad transformers
The classic textbook approach. You compose effects by stacking monad transformers:
import Control.Monad.Reader
import Control.Monad.State
import Control.Monad.Except
type App a = ReaderT Config (StateT AppState (ExceptT AppError IO)) a
Each transformer adds one capability:
ReaderT r— read-only environment.StateT s— mutable state that gets threaded through.ExceptT e— short-circuiting errors.WriterT w— accumulating output (logs, etc. — but use this carefully; the strict version is fine, the lazy one leaks).
You use MonadReader, MonadState, MonadError constraints to write functions that work in any stack containing the transformer they need:
debit :: (MonadReader Config m, MonadState Account m, MonadError AppError m)
=> Money -> m ()
debit amount = do
cfg <- ask
acc <- get
if balance acc - amount < minBalance cfg
then throwError InsufficientFunds
else put (acc { balance = balance acc - amount })
This works. It is what the textbooks teach, and it is genuinely useful when your stack is two or three transformers deep. Past that, the problems start:
- Performance. Each transformer adds an indirection, and GHC cannot always optimize the layers away. Hot paths in deep stacks are noticeably slower.
- Lifting hell.
liftIOis fine.lift . lift . liftIObecause you need to skip past two transformers — less so. There are typeclasses likeMonadIOandMonadUnliftIOto paper over this, but it gets old. - Order matters and is not always intuitive.
StateT s (ExceptT e IO)andExceptT e (StateT s IO)are different — one rolls back state on error, the other does not. Newcomers do not know this until they hit it. - Custom monads each need their own typeclass instances. Tedious, especially with concurrency-related ones (
MonadCatch,MonadMask).
MTL is still the right answer for plenty of code. But "always reach for transformers" is not the consensus it once was. For most application logic, the ReaderT pattern is simpler and effect systems are more powerful.
Option 3: Effect systems
A newer category. The idea: instead of stacking transformers, define your effects as small interfaces, and let an interpreter library handle the wiring. Three serious contenders.
effectful
The current pragmatic favourite. Fast (close to IO performance), simple model, well-maintained, built around a single Eff monad parameterised by a list of effects.
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
import Effectful
import Effectful.Reader.Static
import Effectful.Error.Static
import Effectful.Log
createOrder :: (Reader Config :> es, Error AppError :> es, Log :> es, IOE :> es)
=> UserId -> [Item] -> Eff es OrderId
createOrder uid items = do
logInfo ("creating order for " <> tshow uid)
cfg <- ask
when (length items > orderLimit cfg) $ do
logWarn "too many items"
throwError TooManyItems
insertOrder uid items
You run the program by handing each effect an interpreter:
runApp :: Config -> Pool SqlBackend -> Logger -> Eff '[Reader Config, Log, IOE] a -> IO a
runApp cfg pool logger =
runEff
. runLog logger
. runReader cfg
In tests, you swap the interpreter — runLogToList instead of runLogToStderr, runReaderPure for a config — without changing the business logic. This is the killer feature of effect systems.
effectful is what new projects are most likely to reach for in 2026. The performance cost vs raw IO is small; the ergonomic win is large.
polysemy
More expressive than effectful (it supports "higher-order effects" — effects that take effectful actions as arguments), and the original modern effect system. Cost: slower without aggressive INLINE pragmas, more compiler ceremony, and the type errors can be brutal.
import Polysemy
import Polysemy.Reader
data Counter m a where
Increment :: Counter m ()
GetCount :: Counter m Int
makeSem ''Counter
runCounterIORef :: Member (Embed IO) r => IORef Int -> Sem (Counter ': r) a -> Sem r a
runCounterIORef ref = interpret $ \case
Increment -> embed (modifyIORef ref (+1))
GetCount -> embed (readIORef ref)
Polysemy's design is elegant. In practice many teams have moved to effectful because the build times and error messages were costing them more than the extra expressiveness gained.
freer-simple and friends
An older, simpler "free monad of an open union" approach. Less popular in 2026 than it was — effectful and polysemy have effectively replaced it for new code.
Which to use?
The honest answer depends on your team and codebase.
- Small CLI or script: just
IO. Effect systems are overkill. - Application with 5–10 modules, simple needs: ReaderT pattern. It is the lowest-overhead win once
IO-everywhere starts feeling cluttered. - Application where testability and decoupling matter, new project:
effectful. The interpreter swap is genuinely useful and the performance is fine. - You are already deep in MTL, things work, no reason to change: stay. There is no rush.
- You need higher-order effects or you specifically want polysemy's design: polysemy.
A few teams that have written publicly about their choices: Mercury runs ReaderT-pattern code in production banking. Hasura (GraphQL engine) uses MTL extensively. Several startups have ported Polysemy code to Effectful and reported faster builds. Tweag has written about effectful-style designs in client work.
A note on MonadIO vs MonadUnliftIO
If you write functions polymorphic in their monad, you will run into these.
MonadIO m: you can liftIO aintom avialiftIO. Sufficient for reading and writing.MonadUnliftIO m: you can also "run" the inner monad as anIOaction. Required for things likebracket,forkIO,withAsync— anything that takes a callback and needs to execute it as IO.
unliftio (also Snoyman) is the library that solves the historical mess of "how do I use bracket in a transformer stack". Use it. It is the standard answer for resource-safe code in non-IO monads.
Common pitfalls
Stacking transformers because the textbook said so. Most application logic does not need three transformers deep. Start with ReaderT Env IO and add only when forced.
Putting everything in a giant Env record. Functions then implicitly depend on the whole world. Use typeclasses (HasLogger, HasConfig) to narrow what each function sees.
Choosing an effect library because of a blog post. Each has tradeoffs. Build a small spike before committing a large codebase.
Mixing patterns. A codebase that is half MTL, half effectful is harder to reason about than either pure approach. Pick one and stick with it per service.
Forgetting that effect systems do not eliminate IO. Underneath any interpreter that does real work, there is IO. The benefit is the ability to swap interpreters, not magic purity.
Using WriterT for logging. The lazy one leaks memory; the strict one is fine but other patterns read better. Use a Log effect or a HasLogger capability instead.
Treating MonadIO as a free pass. A function with MonadIO m => can do anything that IO can — read files, call APIs, throw exceptions. Constraining to specific effects (HasDatabase, HasHttpClient) is more honest.
Key takeaways
- The "effects problem" is how to give functions access to capabilities (config, logger, database) without threading them as arguments everywhere.
- Four real approaches: plain
IO, ReaderT pattern, MTL transformers, effect systems (effectful,polysemy). - ReaderT pattern is the simplest upgrade from
IO-everywhere — used in production at Mercury and many Yesod-based apps. - MTL transformers are still useful but stop scaling cleanly past a couple of layers; performance and lifting noise increase with depth.
effectfulis the pragmatic modern effect system: fast, simple, swappable interpreters for testing.polysemyis more expressive but carries compile-time and ergonomic costs; choose deliberately.- Use
MonadUnliftIO(fromunliftio) for resource-safe operations likebracketin non-IO monads. - The point of effect systems is the ability to swap interpreters, not to eliminate IO — IO sits underneath any real implementation.