dep-t
DepT
is a
ReaderT-like
monad transformer for dependency injection.
The difference with ReaderT
is that DepT
takes an enviroment whose type is
parameterized by DepT
itself.
Rationale
To perform dependency injection in Haskell, a common solution is to build a
record of functions and pass it to the program logic using some variant of
ReaderT
.
To avoid becoming tied to a concrete reader environment, let's define some
auxiliary typeclasses that extract functions from a generic environment:
type HasLogger :: (Type -> Type) -> Type -> Constraint
class HasLogger d e | e -> d where
logger :: e -> String -> d ()
type HasRepository :: (Type -> Type) -> Type -> Constraint
class HasRepository d e | e -> d where
repository :: e -> Int -> d ()
We see that the type e
of the environment determines the monad d
on which
the effects take place.
Here's a monomorphic environment record with functions that have effects in IO
:
type EnvIO :: Type
data EnvIO = EnvIO
{ _loggerIO :: String -> IO (),
_repositoryIO :: Int -> IO ()
}
instance HasLogger IO EnvIO where
logger = _loggerIO
instance HasRepository IO EnvIO where
repository = _repositoryIO
Record-of-functions-in-IO is a simple technique which works well in many
situations. There are even specialized
libraries that support it.
Here's a function which can get its dependencies from the monomorphic
environment:
mkControllerIO :: (HasLogger IO e, HasRepository IO e) => Int -> ReaderT e IO String
mkControllerIO x = do
e <- ask
liftIO $ logger e "I'm going to insert in the db!"
liftIO $ repository e x
return "view"
That's all and well, but there are two issues that bug me:
-
We might want to write code that is innocent of IO
and polymorphic over the
monad, to ensure that the program logic can't do some unexpected missile
launch, or to allow testing our app in a "pure" way.
-
What if the repository function needs access to the logger, too? The
repository lives in the environment record, but isn't aware of it. That means
it can't use the HasLogger
typeclass for easy and convenient dependency
injection. Why privilege the controller in such a way?
In a sufficiently complex app, the diverse functions that comprise it will be
organized in a big
DAG of dependencies.
And it would be nice if all the functions taking part in dependency injection
were treated uniformly; if all of them had access to (some view of) the
environment record.
To tackle these issues, we begin by giving the controller a more general signature:
mkControllerIO :: (HasLogger IO e, HasRepository IO e, MonadIO m, MonadReader e m) => Int -> m String
Now the function can work in other reader-like monads besides ReaderT
.
Let's go one step further, and abstract away the IO
, so that functions in the
record can have effects in other monads:
mkController :: (HasLogger d e, HasRepository d e, LiftDep d m, MonadReader e m) => Int -> m String
mkController x = do
e <- ask
liftD $ logger e "I'm going to insert in the db!"
liftD $ repository e x
return "view"
Now both the signature and the implementation have changed:
-
There's a new type variable d
, the monad in which functions taken from the
environment e
have their effects.
-
MonadIO
has been replaced by LiftDep
from Control.Monad.Dep.Class
, a
constraint that says we can lift d
effects into m
(though it could still
make sense to require MonadIO m
for effects not originating in the
environment).
-
Uses of liftIO
have been replaced by liftD
.
If all those constraints prove annoying to write, there's a convenient shorthand using the MonadDep
type family:
mkController :: MonadDep [HasLogger, HasRepository] d e m => Int -> m String
The new, more polymorphic mkController
function can replace the original mkControllerIO
:
mkControllerIO' :: (HasLogger IO e, HasRepository IO e) => Int -> ReaderT e IO String
mkControllerIO' = mkController
Now let's focus on the environment record. We'll parameterize its type by a
monad:
type Env :: (Type -> Type) -> Type
data Env m = Env
{ _logger :: String -> m (),
_repository :: Int -> m (),
_controller :: Int -> m String
}
instance HasLogger m (Env m) where
logger = _logger
instance HasRepository m (Env m) where
repository = _repository
Notice that the controller function is now part of the environment. No
favorites here!
The following implementation of the logger function has no dependencies besides
MonadIO
:
mkStdoutLogger :: MonadIO m => String -> m ()
mkStdoutLogger msg = liftIO (putStrLn msg)
But look at this implementation of the repository function. It gets hold of the
logger through HasLogger
, just as the controller did:
mkStdoutRepository :: (MonadDep '[HasLogger] d e m, MonadIO m) => Int -> m ()
mkStdoutRepository entity = do
e <- ask
liftD $ logger e "I'm going to write the entity!"
liftIO $ print entity
It's about time we choose a concrete monad and assemble an environment record:
envIO :: Env (DepT Env IO)
envIO =
let _logger = mkStdoutLogger
_repository = mkStdoutRepository
_controller = mkController
in Env {_logger, _repository, _controller}
Not very complicated, except... what is that weird DepT Env IO
doing there in
the signature?
Well, that's the whole reason this library exists. For dependency injection to
work for all functions, Env
needs to be parameterized with a monad that
provides that same Env
environment. And trying to use a ReaderT (Env
something) IO
to parameterize Env
won't fly; you'll get weird "infinite
type" kind of errors. So I created the DepT
newtype over ReaderT
to mollify
the compiler.
DepT
has MonadReader
and LiftDep
instances, so the effects of
mkController
can take place on it.
So how do we invoke the controller now?
I suggest something like
runDepT (do e <- ask; _controller e 7) envIO
or
(do e <- ask; _controller e 7) `runDepT` envIO
The companion package
dep-t-advice has some more
functions for running DepT
computations.
How to avoid using "ask" and "liftD" before invoking a dependency?
One possible workaround (at the cost of more boilerplate) is to define helper
functions like:
loggerD :: MonadDep '[HasLogger] d e m => String -> m ()
loggerD msg = asks logger >>= \f -> liftD $ f msg
Which you can invoke like this:
usesLoggerD :: MonadDep [HasLogger, HasRepository] d e m => Int -> m String
usesLoggerD i = do
loggerD "I'm calling the logger!"
return "foo"
Though perhaps this isn't worth the hassle.
How to use "pure fakes" during testing?
The test suite has an example of using a Writer
monad for
collecting the outputs of functions working as "test
doubles".
How to make a function "see" a different evironment from the one seen by its dependencies?
Sometimes we want a function in the environment to see a slightly different
record from the record seen by the other functions, and in particular from the
record seen by its own dependencies.
For example, the function might have a HasLogger
constraint but we don't want
it to use the default HasLogger
instance of the environment.
The companion package
dep-t-advice provides a
deceive
function that allows for this.
How to add AOP-ish "aspects" to functions in an environment?
The companion package
dep-t-advice provides a
general method of extending the behaviour of DepT
-effectful functions, in a
way reminiscent of aspect-oriented programming.
Caveats
The structure of the DepT
type might be prone to trigger a known infelicity
of the GHC
simplifier.
Links
-
This library was extracted from my answer to this Stack Overflow
question.
-
The implementation of mapDepT
was teased out in this other SO question.
-
An SO
answer
about records-of-functions and the "veil of polymorphism".
-
The answers to this SO
question
gave me the idea for how to "instrument" monadic functions (although the
original motive of the question was different).
-
I'm unsure of the relationship between DepT
and the technique described in
Adventures assembling records of
capabilities
which relies on having "open" and "closed" versions of the environment
record.
It seems that, with DepT
, functions in the environment obtain their
dependencies anew every time they are invoked. If we change a function in the
environment record, all other functions which depend on it will be affected
in subsequent invocations. I don't think this happens with "Adventures..." at
least when changing a "closed", already assembled record.
With DepT
a function might use local
if it knows enough about the
environment. That doesn't seem very useful for program logic; if fact it
sounds like a recipe for confusion. It could perhaps be useful for AOP-ish
things, to keep a synthetic
"call stack", or to implement something like Logback's Mapped Diagnostic
Context.
-
RIO is a featureful ReaderT-like /
prelude replacement library which favors monomorphic environments.
-
Another exploration of dependency injection with ReaderT
:
ReaderT-OpenProduct-Environment.
-
The van Laarhoven Free Monad.
Swierstra notes that by summing together functors representing primitive I/O
actions and taking the free monad of that sum, we can produce values use
multiple I/O feature sets. Values defined on a subset of features can be
lifted into the free monad generated by the sum. The equivalent process can
be performed with the van Laarhoven free monad by taking the product of
records of the primitive operations. Values defined on a subset of features
can be lifted by composing the van Laarhoven free monad with suitable
projection functions that pick out the requisite primitive operations.
- registry is a package that
implements an alternative approach to dependency injection, one different
from the
ReaderT
-based one.