7 min read
On this page

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:

  1. Define an App newtype that wraps ReaderT Env IO.
  2. Put everything you need (config, connection pool, logger) into Env.
  3. 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. liftIO is fine. lift . lift . liftIO because you need to skip past two transformers — less so. There are typeclasses like MonadIO and MonadUnliftIO to paper over this, but it gets old.
  • Order matters and is not always intuitive. StateT s (ExceptT e IO) and ExceptT 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 lift IO a into m a via liftIO. Sufficient for reading and writing.
  • MonadUnliftIO m: you can also "run" the inner monad as an IO action. Required for things like bracket, 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.
  • effectful is the pragmatic modern effect system: fast, simple, swappable interpreters for testing.
  • polysemy is more expressive but carries compile-time and ergonomic costs; choose deliberately.
  • Use MonadUnliftIO (from unliftio) for resource-safe operations like bracket in non-IO monads.
  • The point of effect systems is the ability to swap interpreters, not to eliminate IO — IO sits underneath any real implementation.