Effect Management with effectful
Default to effectful for any non-trivial application. Don't use mtl, polysemy, or fused-effects unless the user asks — effectful is modern, fast, and avoids the n² instances problem of mtl-style stacks.
An effect declares an abstract capability (operations the program needs) without committing to an implementation; an interpreter binds those operations to concrete code. Production and tests use different interpreters of the same effect, which is how the same business logic runs against Postgres in production and against an in-memory map in tests.
The pattern
import Effectful
import Effectful.Reader.Static (Reader, runReader, ask)
import Effectful.Error.Static (Error, runError, throwError)
-- Domain effect, defined as a data type (needs GADTs + TypeFamilies)
data UserStore :: Effect where
GetUser :: UserId -> UserStore m (Maybe User)
SaveUser :: User -> UserStore m ()
type instance DispatchOf UserStore = Dynamic
-- Smart constructors
getUser :: UserStore :> es => UserId -> Eff es (Maybe User)
getUser uid = send (GetUser uid)
saveUser :: UserStore :> es => User -> Eff es ()
saveUser u = send (SaveUser u)
-- Business logic is polymorphic over the effect set
registerUser
:: (UserStore :> es, Error AppError :> es)
=> Email -> Eff es User
registerUser email = ...
-- Interpreter for production
runUserStorePostgres
:: (IOE :> es, Reader DbPool :> es)
=> Eff (UserStore : es) a -> Eff es a
runUserStorePostgres = interpret $ \_ -> \case
GetUser uid -> ...
SaveUser u -> ...
-- Interpreter for tests (in-memory)
runUserStoreInMemory :: Eff (UserStore : es) a -> Eff es a
runUserStoreInMemory = ...
Language extensions, in both places
The effect module needs three, and the reason for DataKinds is not obvious:
{-# LANGUAGE DataKinds #-} -- 'Dynamic' in the DispatchOf instance is a promoted constructor
{-# LANGUAGE GADTs #-} -- the effect is declared as a GADT
{-# LANGUAGE TypeFamilies #-} -- 'type instance DispatchOf'
Without DataKinds the failure names the wrong thing:
Not in scope: type constructor or class 'Dynamic'
Perhaps you intended to use DataKinds to refer to the data constructor of that name?
Every interpreter module needs GADTs too, even though it only pattern matches:
{-# LANGUAGE GADTs #-}
Matching the effect's constructors inside interpret $ \_ -> \case is a GADT match, and the
type refinement is only sound with MonoLocalBinds, which GADTs implies. It compiles without
the extension, so the mistake is easy to keep — GHC only warns:
[GHC-58008] [-Wgadt-mono-local-binds]
Pattern matching on GADTs without MonoLocalBinds is fragile.
Note that an incremental build will not re-emit that warning for an already-compiled module.
Build into a fresh --builddir when checking whether the warning is really gone.
Key rules
- One effect per concern. Don't pile everything into one
Appmonad.UserStore,EmailSender,PaymentGatewayare separate effects. IOonly at the edges. TheIOEconstraint stays in interpreters andmain, not in business logic. If a function in your domain layer needsIOE, that's a smell — model the side-effecting capability as its own effect.- Static effects when possible.
Reader.StaticandError.Staticare faster than their dynamic counterparts. Use dynamic only when you genuinely need to swap implementations at runtime (rare). - No
MonadIO/liftIOin app code. That's mtl-land. Witheffectfulyou useliftIOfromEffectfulonly at the interpreter boundary, almost never in business logic. - Smart constructors per operation. Don't expose
send (GetUser uid)to callers — wrap it asgetUser uid. Keeps call sites clean.
Composition in main
The composition root assembles the interpreter stack — this is where the abstract operations get bound to concrete implementations. Order matters: interpreters peel effects off the stack one at a time.
main :: IO ()
main = do
pool <- createPool dbConfig
result <- runEff
. runReader pool
. runErrorNoCallStack @AppError
. runUserStorePostgres
$ registerUser someEmail
case result of
Left err -> ...
Right user -> ...
Read top to bottom: runEff discharges IOE, runReader provides the pool, runError peels off the error effect (turning it into Either), and runUserStorePostgres implements UserStore. Each run* removes one effect from the stack.
When to use Dynamic vs Static
Dynamic(type instance DispatchOf E = Dynamic): the interpreter is chosen at runtime. Needed when production and tests use different implementations of the same effect — the typical case for application-level effects likeUserStore,EmailSender.Static: the implementation is fixed at compile time. Faster, less flexible. Use for foundational concerns where the implementation is genuinely universal:Reader env,State s,Error e.
Default new application-domain effects to Dynamic. Default infrastructure-shaped effects to Static.
Related
- Domain errors flowing through the
Erroreffect: seehaskell-domain-errorsfor the rules on error types (especially: don't deriveExceptionfor errors that travel viaErroreffect). - Logging as an effect (
Log :> es): seehaskell-logging. - In-memory interpreters in tests: see
haskell-testing. - Language extensions needed (
GADTs,TypeFamilies,DataKinds): seehaskell-project-setup.