The Environment Functions pattern utilizes first class effects to provide dependency inversion at a more granular level with less boilerplate than other patterns. The core idea is to define function types that are effect polymorphic, allowing implementations to utilize effects that aren't leaked into abstractions.

It’s often the case that dependency inversion patterns do a good job of hiding some implementation details, like what dependencies are required to instantiate some specific implementation or what backend/data retrieval process is being exactly utilized. However they are bad at hiding other implementation details, namely most effects. If your repository’s implementation has functions that require asynchronicity, error handling, or branching capability, those details will leak into the abstraction, otherwise the implementation’s function signature wouldn’t be valid.

The Environment Function Link to heading

This pattern differs from most other dependency inversion patterns in that we’re not defining an abstract type to hold a bunch of abstract functions, we just want one abstract function.

So while you might be used to something like:

data MatchInfoSource m = MatchInfoSource
  { fetchPlayerIds :: m [String]
  , fetchMatchIds  :: String -> m [String]
  , fetchMatch     :: String -> m String
  }

We’d instead have each function in its own abstraction.

-- Using longer GADT syntax here instead of newtypes for later use with polysemy
data FetchPlayerIds m a where FetchPlayerIds :: FetchPlayerIds m [String]
data FetchMatchIds m a where FetchMatchIds :: String -> FetchMatchIds m [String]
data FetchMatch m a where FetchMatch :: String -> FetchMatch m String

At first this probably sounds terrible, you’d have some crazy parameter explosion. Where previously you might have required 3 interfaces, you’d now need easily a dozen!

This is where another important component of the pattern comes in, the functions are actually required by the environment. What this means exactly is dependent on the language/framework. Sometimes what looks like just normally calling the function is actually requiring the function in the current environment and then calling it. Other times you’re building up sequence of data structures that will be later interpreted. For the purposes of this pattern, these two approaches are very similar.

Now what does “required by the environment” actually mean? It means rather than passing arguments down a chain of function calls, dependencies are resolved by some other mechanism. There are several things to consider here

  • Are dependencies resolved at compile time or runtime?
  • Does our method of dependency management compose with other effects?
  • How much boilerplate is there for resolving dependencies?

In Haskell, we generally reach for the reader effect. In transformer code this means specifically using ReaderT, in polysemy and mtl it means having a Reader constraint (in slightly different forms though). You can read my explanation on readers here. In these contexts, effects are first class and we can compose them seamlessly

fetchAndSave userId = do
  source <- ask @Database
  api <- ask @HttpClient

  user <- api.fetch(userId)
  db.save(user)