So, something that tends to be more global, like a log or configuration, would you suggest putting an IO monad? From the look (admittedly a very limited set) of examples, I come to think that Haskell code tends to be either clean (i.e. not monadic at all) or in the IO monad. Or is it a delusion?
I think this is a fallacy, only the IO monad is not clean. monads, such as Write / T / Reader / T / State / T / ST monads, are purely functional. You can write a pure function that uses any of these monads inside, like this completely useless example:
foo :: Int -> Int foo seed = flip execState seed $ do modify $ (+) 3 modify $ (+) 4 modify $ (-) 2
All this makes the flows / plumbing state implicitly, what you do manually yourself, explicitly, do-notation just gives you good syntactic sugar to make it look imperative. Here you cannot perform any input / output operations, you cannot name any external functions. ST monad allows you to have real mutable links in the local area, having a clean function interface, and you cannot perform any I / O operations where it is purely functional.
You cannot avoid some I / O operations, but you do not want to return to IO for everything, because everything can go there, you can launch rockets, you have no control. Haskell has abstractions for managing efficient computing with varying degrees of security / purity, the IO monad should be the last resort (but you cannot completely avoid it).
In your example, I think you should stick to using custom-made monad or monad transformers, which does the same thing as composing them with transformers. I never wrote a custom monad (but so far), but I used monad transformers quite a bit (my own code, not at work), don’t worry about them so much, use them, and it’s not as bad as you think.
Have you seen a chapter from Real World Haskell that uses monad transformers ?