dep-t: Reader-like monad transformer for dependency injection.

[ bsd3, control, library ] [ Propose Tags ]

Put all your functions in the environment record! Let all your functions read from the environment record! No favorites!


[Skip to Readme]

Modules

[Index] [Quick Jump]

Downloads

Maintainer's Corner

Package maintainers

For package maintainers and hackage trustees

Candidates

  • No Candidates
Versions [RSS] 0.1.0.0, 0.1.0.1, 0.1.0.2, 0.1.1.0, 0.1.2.0, 0.1.3.0, 0.4.0.0, 0.4.0.1, 0.4.0.2, 0.4.4.0, 0.4.5.0, 0.4.6.0, 0.5.0.0, 0.5.1.0, 0.6.0.0, 0.6.1.0, 0.6.2.0, 0.6.3.0, 0.6.4.0, 0.6.5.0, 0.6.6.0, 0.6.7.0, 0.6.8.0
Change log CHANGELOG.md
Dependencies base (>=4.10.0.0 && <5), mtl (>=2.2 && <2.3), transformers (>=0.5.0.0 && <0.6), unliftio-core (>=0.2.0.0 && <0.3) [details]
License BSD-3-Clause
Author Daniel Diaz
Maintainer diaz_carrete@yahoo.com
Category Control
Source repo head: git clone https://github.com/danidiaz/dep-t.git
Uploaded by DanielDiazCarrete at 2021-01-23T11:38:05Z
Distributions
Reverse Dependencies 4 direct, 0 indirect [details]
Downloads 3250 total (31 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs available [build log]
Last success reported on 2021-01-23 [all 1 reports]

Readme for dep-t-0.1.3.0

[back to package description]

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 achieve 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.

Let's start by defining some auxiliary typeclasses to extract functions from an environment record:

type HasLogger :: Type -> (Type -> Type) -> Constraint
class HasLogger r m | r -> m where
  logger :: r -> String -> m ()

type HasRepository :: Type -> (Type -> Type) -> Constraint
class HasRepository r m | r -> m where
  repository :: r -> Int -> m ()

We see that the type of the record determines the monad in which the effects take place.

Let's define a monomorphic record with effects in IO:

type EnvIO :: Type
data EnvIO = EnvIO
  { _loggerIO :: String -> IO (),
    _repositoryIO :: Int -> IO ()
  }

instance HasLogger EnvIO IO where
  logger = _loggerIO

instance HasRepository EnvIO IO 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 obtains its dependencies from the environment record:

mkControllerIO :: (HasLogger e IO, HasRepository e IO) => 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:

  • 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.

  • 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.

Let's parameterize our environment by a monad:

type Env :: (Type -> Type) -> Type
data Env m = Env
  { _logger :: String -> m (),
    _repository :: Int -> m (),
    _controller :: Int -> m String
  }
-- helper from the "rank2classes" package
$(Rank2.TH.deriveFunctor ''Env)

instance HasLogger (Env m) m where
  logger = _logger

instance HasRepository (Env m) 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:

mkStdoutRepository :: (MonadReader e m, HasLogger e m, MonadIO m) => Int -> m ()
mkStdoutRepository entity = do
  e <- ask
  logger e "I'm going to write the entity!"
  liftIO $ print entity

And here's the controller:

mkController :: (MonadReader e m, HasLogger e m, HasRepository e m) => Int -> m String
mkController x = do
  e <- ask
  logger e "I'm going to insert in the db!"
  repository e x
  return "view"

Now, lets choose IO as the base 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. Trying to use a ReaderT (Env something) IO to parameterize Env won't fly; you'll get weird "infinite type" kind of errors because the Env needs to be parameterized with the monad that provides the Env environment. So I created the DepT newtype over ReaderT to mollify the compiler.

So how do we invoke the controller now?

I suggest something like

runDepT (do e <- ask; _controller e 7) envIO 

How to embed environments into other environments?

Sometimes it might be convenient to nest an environment into another one, basically making it a field of the bigger environment:

type BiggerEnv :: (Type -> Type) -> Type
data BiggerEnv m = BiggerEnv
  { _inner :: Env m,
    _extra :: Int -> m Int
  }
$(Rank2.TH.deriveFunctor ''BiggerEnv)

When constructing the bigger environment, we have to tweak the monad parameter of the smaller one, to make the types match. This can be done with the zoomEnv function:

biggerEnvIO :: BiggerEnv (DepT BiggerEnv IO)
biggerEnvIO =
  let _inner' = zoomEnv (Rank2.<$>) _inner envIO
      _extra = pure
   in BiggerEnv {_inner = _inner', _extra}

We need to pass as the first parameter of zoomEnv a function that tweaks the monad parameter of Env using a natural transformation. We can write such a function ourselves, but here we are using the function generated for us by the rank2classes TH.

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 avoid using "ask" or "asks" before invoking a dependency?

One possible workaround (at the cost of more boilerplate) is to define helper functions like:

logger' :: (MonadReader e m, HasLogger e m) => String -> m ()
logger' msg = asks logger >>= \f -> f msg

Which you can invoke like this:

mkController x = do
  logger' "I'm going to insert in the db!"

I'm not sure it's worth the hassle.

How to instrument functions in the environment?

Once we have commited to a concrete monad and constructed our record-of-functions, we might indulge in a bit of low-calorie aspect-oriented-programming.

For example, imagine we want a generic way of adding logging of function parameters to any function in the environment, provided the environment already contains a logging function.

We can write the following typeclass:

class Instrumentable e m r | r -> e m where
  instrument ::
    ( forall x.
      HasLogger (e (DepT e m)) (DepT e m) =>
      [String] ->
      DepT e m x ->
      DepT e m x
    ) ->
    r ->
    r

Which means "if you tell me how to transform a terminal DepT action, using the list of preceding arguments, in an environment that has as logger, then I'll be able to transform any function which ends in DepT".

The terminal case is a DepT without preceding parameters:

instance HasLogger (e (DepT e m)) (DepT e m) => Instrumentable e m (DepT e m x) where
  instrument f d = f [] d

The recursive case handles functions argument by argument:

instance (Instrumentable e m r, Show a) => Instrumentable e m (a -> r) where
  instrument f ar =
    let instrument' = instrument @e @m @r
     in \a -> instrument' (\names d -> f (show a : names) d) (ar a)

Here's how to add logging advice to the controller function:

instrumentedEnv :: Env (DepT Env (Writer TestTrace))
instrumentedEnv =
   let loggingAdvice args action = do
            e <- ask
            logger e $ "advice before " ++ intercalate "," args
            r <- action
            logger e $ "advice after"
            pure r
    in env { _controller = instrument loggingAdvice (_controller env) }

More complete advice support can be found in the dep-t-advice package.

Caveats

The structure of the DepT type might be prone to trigger a known infelicity of the GHC simplifier.

  • 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.

    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 an already "assembled" record.

  • RIO is a featureful ReaderT-like / prelude replacement library which favors monomorphic environments.

  • Another exploration of dependency injection with ReaderT: ReaderT-OpenProduct-Environment.

  • registry is a package that implements an alternative approach to dependency injection, one different from the ReaderT-based one.