Stacking Effects in Haskell with Monad Transformers
Combine configuration, error handling, and IO in one Haskell stack with ReaderT, ExceptT, and monad transformers. Includes a server example and limits to check.
09 Oct 2026, 13:43 UTC

The Problem: One Handler, Three Kinds of Effects
A typical request handler needs to read configuration, report business errors, and perform IO such as writing a response. If you handle each effect separately, you end up threading config values through every function, wrapping results in Either at every step, and calling runX repeatedly at the edges. The code works, but the seams show.
Thesis: A Fixed Transformer Stack Is a Good Default
When the set of effects is known up front—environment, exceptions, and base IO—monad transformers compose them into one type. ReaderT carries read-only configuration, ExceptT short-circuits on errors, and IO sits at the bottom for side effects. The mtl typeclasses let you use ask, throwE, and liftIO without manually unwrapping layers.
Worked Example: A Minimal Handler Stack
ReaderT r m a adds a read-only environment of type r to monad m. ExceptT e m a adds short-circuiting errors of type e. Stacking them over IO gives a single monad for the whole handler.
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Reader (ReaderT, ask, runReaderT)
import Control.Monad.Trans.Except (ExceptT, throwE, runExceptT)
import Network.HTTP.Types (status200, status404, status500)
import Network.Wai (Application, responseLBS)
import Network.Wai.Handler.Warp (run)
import Data.ByteString.Lazy.Char8 (pack)
data Config = Config
{ cfgPort :: Int
, cfgGreeting :: String
}
data AppError = NotFound | InternalError String
deriving (Show)
-- | One stack: configuration, errors, then IO.
type App a = ReaderT Config (ExceptT AppError IO) a
handleRequest :: App String
handleRequest = do
cfg <- ask
if null (cfgGreeting cfg)
then throwE NotFound
else pure ("Hello, " ++ cfgGreeting cfg)
runApp :: Config -> App a -> IO (Either AppError a)
runApp cfg app = runExceptT (runReaderT app cfg)
app :: Config -> Application
app cfg _req sendResponse = do
result <- runApp cfg handleRequest
case result of
Left NotFound ->
sendResponse $ responseLBS status404 [("Content-Type", "text/plain")] "Not found"
Left (InternalError msg) ->
sendResponse $ responseLBS status500 [("Content-Type", "text/plain")] (pack msg)
Right body ->
sendResponse $ responseLBS status200 [("Content-Type", "text/plain")] (pack body)
main :: IO ()
main = do
let cfg = Config { cfgPort = 3000, cfgGreeting = "world" }
putStrLn ("Listening on port " ++ show (cfgPort cfg))
run (cfgPort cfg) (app cfg)
Running and Checking the Example
Run these commands in a terminal inside a Haskell project that depends on wai, warp, and http-types. No special permissions are required; do not run the server as root.
# Compile with Stack or Cabal (GHC 9.6 or later assumed).
$ stack build
$ stack exec -- my-app-executable
# In another terminal, send a request.
$ curl -i http://localhost:3000/
Expected check: the server prints the listening line, and curl returns HTTP/1.1 200 OK with body Hello, world. Change cfgGreeting to an empty string and restart; the handler should return 404 Not found. Risks: port 3000 may already be in use—edit cfgPort if it is. Missing packages cause build errors; add them to your build file's build-depends.
Trade-offs and Limits
- Deep stacks make type signatures long. Use a type alias such as
Appso modules do not repeat the full transformer chain. - Recursive code inside the stack can overflow the call stack because each transformer layer adds a closure. Keep heavy recursion in
IOor rewrite it iteratively. - Lifting
IOactions withliftIOis explicit but verbose in large modules.
Alternatives such as free monads or effect libraries (for example, polysemy or eff) avoid some lifting boilerplate, but they add their own complexity and are not needed for a fixed three-layer stack.
Practical Closing
Start with ReaderT Config (ExceptT AppError IO) when you need config, errors, and side effects together. Run the full stack once at the top level with runReaderT and runExceptT, keep the stack shallow, and verify by compiling and hitting the endpoint with curl. If the stack grows beyond three or four layers, reconsider whether the effects belong in separate modules or a different effect system.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.