I’ve been experimenting with a way of structuring Haskell applications where components are records-of-functions, and when one component is a dependency of another it gets passed as a parameter of the latter’s constructor function.
So no effect systems, no MTL-like typeclasses, no monad transformer stacks, no ReaderT
even. Or so I thought!
Turns out that ReaderT
was still needed in one case: request-scoped values. For example, in Servant it’s a common pattern to reserve a database connection from a connection pool at the beginning of each request, and then pass down the connection in the environment of a ReaderT
.
Eric Torreborre makes a similar point about the need for ReaderT
at 33:05 in his “Wire all the things” talk, which is about a wiring system for applications (among other things).
But I was somewhat unsatisfied with that use of ReaderT
because:
- It’s a transformer, and I was trying to see if I could do without them. The idea was to simply pass records around as regular arguments. Can we do everything that way?
- It forces the component definitions to either know about the
ReaderT
, or to be polymorphic on the monad. Not a big deal, but what if I wanted simple non-polymorphic functions working inIO
?
As an alternative to ReaderT
, I turned to a form of thread-local data. The idea is that we have an IORef
containing a map indexed by ThreadId
. At the beginning of a request, we get a database connection from the pool and associate it in the map with the current ThreadId
. Downwards in the call chain, the repository component is injected with the map (actually, with a view of it) and gets the connection corresponding to the current ThreadId
from it.
As for the intermediate components in the call chain, they are not injected with the map and don’t know anything about the current connection. (Formerly, the “don’t know anything” would be accomplished by being polymorphic on the monad.)
It works, although I’m unsure if it’s improvement. Possible disadvantages:
- Less type-safe. If we forget to set the connection in the map, the application will compile, but fail at runtime when the repository tries to get the connection.
- There will be concurrency overhead in managing the
ThreadId
-indexed map.
This is the branch that uses ReaderT
. And this is the branch that uses thread-local data. This is the diff.