Cauldron: a toy dependency injection framework

I’ve been playing at writing a toy dependency injection framework that uses dynamic types under the hood. The cauldron library is the result. Basically, it wires a bunch of component constructors for you, so you don’t have to do it manually at the composition root of your app.

By “components” (or “beans”) I mean a bunch of records-of-functions with effects in IO. A way of structuring applications that doesn’t even use the ReaderT monad. Components get their dependencies as regular parameters, and that’s it. (I’m actually curious about why this architecture isn’t more popular in Haskell-land. Why reach always for ReaderT, transformers, or other effect systems?)

Anyway, the good news is that it seems to work. The bad news is that it doesn’t seem less verbose that wiring things manually. For comparison, here is a manual wiring of some toy components, and here is the same wiring using Cauldron.

In any case, this project has helped me learn about dynamically typed Haskell. Type.Reflection, Data.Typeable, Data.Type.Equality and all that. It’s actually a fun and useful part of the language!

4 Likes

Seems relevant for the pattern I wrote about!

I’m not sure how to read your example, though. How might this actually look in a real scenario, e.g. to write a function that reads from a database and makes a network request?

At first glance, it looks similar to how Scala zio registers all the components in an application, and while it’s all located in one place, that one file is a huge mess of wiring things up. Maybe we just haven’t used the pattern at scale yet, but we haven’t had too many issues yet with regard to wiring up the different components.

3 Likes

This may help to explain that tendency:

https://web.archive.org/web/20070614151632/http://www.haskell.org/hawiki/NotJustMaybe

…and “committing early” to the monadic style also helps to avoid the annoying monadic refactoring of regular (un-monadic) Haskell code later, particularly if there’s a chance of I/O being needed in the future :-(

I’ve kept tinkering with this. I’ve tweaked the api and wrote a list of analogies with how DI works in the Java Spring Framework.

I’m not sure how to read your example, though. How might this actually look in a real scenario, e.g. to write a function that reads from a database and makes a network request?

As a slightly more realistic example, I’ve created a trivial web application with sqlite persistence. The wiring code looks like this:

cauldron :: Cauldron Managed
cauldron = do
  let liftConIO = hoistConstructor liftIO
  emptyCauldron
    & do
      let makeJsonConf = Bean.JsonConf.YamlFile.make do Bean.JsonConf.YamlFile.loadYamlSettings ["conf.yaml"] [] Bean.JsonConf.YamlFile.useEnv
      insert @(JsonConf IO) do makeBean do liftConIO do pack effect do makeJsonConf
    & insert @Logger do makeBean do pack effect do managed withStdOutLogger
    & insert @SqlitePoolConf do makeBean do liftConIO do pack effect do Bean.JsonConf.lookupSection @IO "sqlite"
    & insert @SqlitePool do makeBean do pack effect \conf -> managed do Bean.Sqlite.Pool.make conf
    & insert @(CurrentConnection M) do makeBean do pack value do Bean.Sqlite.CurrentConnection.Env.make id
    & insert @(CommentsRepository M) do makeBean do pack value do Comments.Repository.Sqlite.make
    & insert @(CommentsServer MH) do makeBean do pack value makeCommentsServer
    & insert @RunnerConf do makeBean do liftConIO do pack effect do Bean.JsonConf.lookupSection @IO "runner"
    & insert @Runner do makeBean do pack value makeRunner

Still kind of verbose I guess. Well, at least I got a nice dependency graph out of it:

Wiring errors are detected at runtime. For example, if I delete the insert @Logger line, the project will compile anyway, but when I try to run the server, I’m met with:

MissingDependencies [] (fromList [(Runner,fromList [Logger]),(CommentsServer (ReaderT Connection Handler),fromList [Logger]),(CommentsRepository (ReaderT Connection IO),fromList [Logger])])

Manual wiring or not, I like this way of structuring Haskell applications. Use records-of-functions to represent your components, make the components’ constructors take their dependencies as plain old function arguments, and wire the constructor together in a place near the “top” of the application, close to Main. The place where you wire things together is also good for adding logging, instrumentation and other decorations.

…and for those who have difficulty reading line noise:

cauldron :: Cauldron Managed
cauldron = do
  let liftConIO = hoistConstructor liftIO
  emptyCauldron
    & do
      let makeJsonConf = Bean.JsonConf.YamlFile.make (Bean.JsonConf.YamlFile.loadYamlSettings ["conf.yaml"] [] Bean.JsonConf.YamlFile.useEnv)
      insert @(JsonConf IO) $ makeBean liftConIO (pack effect) makeJsonConf
    & insert @Logger $ makeBean (pack effect) (managed withStdOutLogger)
    & insert @SqlitePoolConf $ makeBean liftConIO (pack effect) (Bean.JsonConf.lookupSection @IO "sqlite")
    & insert @SqlitePool $ makeBean (pack effect $ \conf -> managed) (Bean.Sqlite.Pool.make conf)
    & insert @(CurrentConnection M) $ makeBean (pack value) (Bean.Sqlite.CurrentConnection.Env.make id)
    & insert @(CommentsRepository M) $ makeBean (pack value) Comments.Repository.Sqlite.make
    & insert @(CommentsServer MH) $ makeBean (pack value) makeCommentsServer
    & insert @RunnerConf $ makeBean liftConIO (pack effect) (Bean.JsonConf.lookupSection @IO "runner")

But if DST (do-separated terms) is going the be the (ahem) “future” of Haskell:

https://web.archive.org/web/20050409044246/http://www.scannedinavian.org/iohcc/zeroth-2003/