At what size of codebase / program do various architectures pay off?
For instance, sub 500 lines, you can easily write in a script-like dialect without rigorous IO / pure separation (pure functions exist in let blocks or where clauses), and get more from simplicity and speed of writing than you lose from more difficult testing.
On the other hand, at what scales does MTL pay off? What about free / freer monad interpreters? How big do you have to get before not using an effect system starts to feel like a mistake?
It may not surprise you to know that I’d use Bluefin at every scale, even for sub 500 line scripts. Scoped exceptions and streams are just too good to miss out on.
The only questions I ask when I move from “small, quick script” to “large, maintained codebase” is “how far do I pass this powerful handle down the stack?” and “when do I want to combine multiple handles into one?”. I’d probably want to avoid passing IOE down more than, say, three of four function calls.
For example, here’s my shell prompt. It’s thoroughly Bluefinized. You can see I wraped IOE in a Git handle, to avoid passing naked IOE down too far.
In every case, the most guiding advice I’ve ever received was:
This kinda of passing fundamental handles/basic values to every function makes it pretty trivial in my experience to refactor from one style into another because your core logical functions that don’t need to do anything effectful can just be reused everywhere.
So long as you can get the a to the function that needs it, you’re good.
My current problem right now is figuring out how to do logging but to different handlers and how to combine handlers, trying to do it myself so I get an intuition for the design of how to attach logging to my otherwise pure functions.
Right now, what I’ve been exploring trying to model is passing a Logger handle to each function: I know an easy way to do this may be with IORef/MVar [String/Text]. WriterT/StateT require me to bake it as part of some data structure (I’ve heard Writer there’s major perfomance/space implications with Writer.) I think I can pass WriterT/StateT handles around, but I will need to always exec their functions to get the final result: which I’m fine with - I did something similar for using Bluefin.State for logging my GameState
Functions that look like these is what I’ve been trying to model in all the different styles:
data Logger phantom = Logger { mkLogger :: someAccumulationType } -- Phantom could maybe help me not have incompatible actions writing to some loggers?
program :: IO () -- (?)
program = do
Logger loggerIO <- (pure (mkLogger logIOHandle) :: Logger IO)
Logger loggerint <- (pure (mkLogger logIntHandle) :: Logger Int)
...
...
logAction (putStrLn "Hello") "Printing Hello" loggerIO
res <- logAction (pure (2 + 2)) "Adding two numbers" loggerInt
putStrLn $ "The result of adding the numbers was " <> show res
logAction :: Monad m => m a -> Text -> Logger p -> m a
logAction action logmsg logger = do
a <- (lift?) action
writeToLog logger logmsg
pure a
writeToLog = undefined -- dependent on what the handle is from the looks of things