Dependency Injection is a technique to make the classes in Object Oriented Programming easier to test and configure. Instead of a class instantiating its own concrete implementations, it instead has them injected into it. In Functional Programming, that’s a fancy way of saying “calling a function with parameters”. However, it’s not that these parameters are data, but rather the same thing type of dependencies you’d use in OOP: some type of module or function that does a side effect, and you want to make your function easier to test.
In this article we’ll show you how OOP uses DI to make the classes easier to test, then we’ll show the same technique in FP using JavaScript for both implementations. Code is on Github. After reading this you’ll understand how to make your FP code easier to test and configure, just like you do in OOP style coding.
Mark Seemann did a conference talk about using Partial Application to do Dependency Injection in Functional Programming.
I loved his video. I felt if you’re new, you don’t need to know how partial application works in Functional Programming to understand how to do Dependency Injection. It really is just passing arguments to functions. Once you learn that, the you can go learn about Partial Applications and continue to use your Dependency Injection skills in more advanced ways.
If you already know what Dependency Injection is and how to use it in Object Oriented Programming, you can skip to the Functional Programming explanation.
What is Dependency Injection?
Dependency Injection is a technique for instantiating classes that conform to an interface, and then instantiating another class that needs them, and passing them into that class’ constructor. A dependency is a class that typically does some complex side effect work, such as connecting to a database, getting some data, and parsing its result. It’s also sometimes called Inversion of Control because you have a DI container manage creating all these classes and giving them to who needs them vs. you, the developer making a parent class, then hard-coding internally those composed classes; computer vs. you, dependencies are given to class instead of class making them itself. You as the developer just give the DI container some configuration in the form of “This class needs this interface” ( a la TypeScript). In tests, the DI container will give it the stub/mock/fake implementation. When your program runs for real, the DI container will give it the real/concrete implementation; same code, but different dependencies depending upon if you’re running in test mode or real mode.
There is much that DI helps, but for the focus of this article, it makes testing classes easier. While they can abstract and encapsulate their behavior, you can leave them open to modify and configure how they work without having to change the class itself.
The Un-Testable OOP Problem
Classes are built to encapsulate state. State could be an internal variable, a bunch of variables, database connections, and many things happening at once. This is typically a good thing in the OOP world: you abstract away complexity so those who use your class have a simple way to interact with and control that complexity.
There are 2 challenges with that:
- How do you know it actually works?
- Do you actually feel like it’s a simple design that you like to use?
For the first, we use some type of integration tests; using real data and real connections or even functional tests knowing that piece of code will be tested with the rest. This lets us know in an automated way if it works now, and if we change things later, it continues to work then.
For the 2nd, we try to use a test first methodology like Test Driven Development, to start consuming our class’ API before it even exists, and design what we like. Once we have something we might like, we make the class work with the bear minimum of code. Later, we can then refactor and tweak the design to our hearts content… or some deadline.
Let’s not do that. Let’s show a class that was just built to work without being testable first, with no dependency injection. Here is one called Config that reads what environment we’re in, QA or Production, based on reading a JSON file. This is a common need in server and client applications where you use a configuration file or environment variables to tell your application what URL’s to use for REST API’s. In QA, you’ll use 1 set of URL’s, and in Production, a different set. This allows your code to work in multiple environments by just configuring it.
import JSONReader from './JSONReader.mjs'
class Config {
Notice it imports a JSONReader
class who’s sole job is read a JSON file from disk, parse it, and give back the parsed JSON Object. The only public method in this class is a one that takes no parameters, and gives back a URL to use for QA or Production:
getServerURL() {
let environment = this.#getEnvironment()
let url = this.#getURLFromEnvironment(environment)
return url
}
The getEnvironment
private method abstracts away how that works; we just want to know is it “qa” or “production”? Once we have one of those 2, we can call the getURLFromEnvironment
private method and it’ll give us the correct URL based on environment.
If we look at the private getEnvironment
method:
#getEnvironment() {
return new JSONReader('config.json')
.getConfigJSON()
.env
}
We see it’s using that concrete implementation of the JSON reader to read a file, and pluck off the “env” variable which will be “qa” or “production”.
The getURLFromEnvironment
private method is our only pure function here:
#getURLFromEnvironment(environment) {
if(environment === 'production') {
return 'http://server.com'
} else {
return 'http://localhost:8000'
}
}
If you give it a string, it’ll give you a string back. There are no side effects; this is our only logic in the class.
So unit testing this class in a whitebox manner is hard; the only way you can configure this thing is by changing a “config.json” file on disk that is relative to where this class is. Not really that configurable, and it has disk access which isn’t necessarily slow nowadays, but some other side effect that is required to be setup to make this class work, so not fun to work with.
The Testable OOP Class
Let’s slightly modify this class to be easier to configure; namely the JSONReader
that does the main side effect, we’ll make that a constructor parameter instead.
class Config {
#JSONReader
constructor(JSONReader) {
this.#JSONReader = JSONReader
}
Now, we pass our JSONReader
as a parameter when we instantiate the class. This means we can pass a stub in our tests, and a real implementation in our integration tests and in our application, all while using the same class. None of the implementation details change; instead of using the concrete implementation, our private methods just now use the private internal instance variable:
#getEnvironment() {
return this.#JSONReader
.getConfigJSON()
.env
}
Great! Now we can write a unit test that stubs this disk & JSON parsing side effect into something that’s deterministic and fast. Here’s our stub:
class JSONReaderStub {
getConfigJSON() {
return { env: 'qa' }
}
}
This class will always work and always return QA. To setup our Config class, we’ll first instantiate our stub, then our Config class, and pass our stub into the constructor:
let jsonReaderStub = new JSONReaderStub()
let config = new Config(jsonReaderStub)
This new implementation change makes the Config class configurable now, we can do the same thing for unhappy paths as well, such as the when the file doesn’t exist, we don’t have permission to read the file, we read the file but it fails to successfully parse as JSON, it parses as valid JSON, but the environment is missing, and the environment is there but it is not QA or Production… all of these are just stubs passed in, forcing Config to handle those code paths.
Now, we can test the functionality with confidence:
let url = config.getServerURL()
expect(url).to.equal('http://localhost:8000')
Integration Test
Your integration tests, used to validate your Config class can successfully read a config JSON file and glean the correct HTTP URL to use based on the environment, require a real JSON file reader. Our JSONFileReader class follows the same practice of making it self configurable:
class JSONReader {
#FileReader
#configFileName
constructor(FileReader, configFileName) {
Which means in the unit test, that FileReader would be a stub, and in our integration tests, would be real. We do that by using the injected dependency is a stored private variable:
getConfigJSON() {
return JSON.parse(this.#FileReader.readFileSync(this.#configFileName))
}
This means we can configure it to work for real in the integration tests with our Config. We’ll make it real:
let jsonReader = new JSONReader(fs, './test/integration/qa-config.json')
let config = new Config(jsonReader)
The fs
is the Node.js module that reads and writes files. The file path to qa-config.json is a real file we have setup to verify this class can read it and give us the correct URL. The test looks the same… because it is, the only difference is the dependencies are real instead of stubs:
let url = config.getServerURL()
expect(url).to.equal('http://localhost:8000')
Functional Programming Config
Doing the equivalent functionality in Functional Programming requires a function to read the file, parse it, snag off the environment, and determine which URL to return based on that environment. We do that by making each of those steps a function, and composing them together. We’re using the Stage 2 JavaScript pipeline operator below in F# style:
import fs from 'fs'
const getServerURL = fileName =>
fileName
|> fs.readFileSync
|> JSON.parse
|> ( json => json.env )
|> ( environment => {
if(environment === 'production') {
return 'http://server.com'
} else {
return 'http://localhost:8000'
}
})
Before we proceed, if you’re uncomfortable with or have never the pipeline operator, just think of it as synchronous way to chain functions together, just like you do using Promises. Here is the Promise version of the code:
const getServerURL = fileName =>
Promise.resolve( fileName )
.then( fs.readFileSync )
.then( JSON.parse )
.then( json => json.env )
.then( environment => {
if(environment === 'production') {
return 'http://server.com'
} else {
return 'http://localhost:8000'
}
} )
Right off the bat, the FP code has the same problem as the OOP code; the reading from disk and parsing JSON side effects are encapsulated away. The fs
module is imported up top as a concrete implementation, and used inside the function closure. The only way to test this function is to muck around with config files; lamesauce.
Let’s refactor it like we did with the OOP code to have the dependency be injectable; aka able to be passed in as a function parameter:
const getServerURL = (readFile, fileName) =>
fileName
|> readFile
|> JSON.parse
Nice, now readFile
, formerly the concrete implementation fs.readFileSync can be passed in as a parameter. This means this function can be configured in multiple ways, but 2 important ones: a stub readFile for the unit test, and a real readFile for the integration test. Here’s the unit test stub:
const readFileStub = () => `{ "env": "qa" }`
It’s guaranteed to work, and JSON.parse will always succeed with it, and our function should in theory always return our QA url; let’s test:
const url = getServerURL(readFileStub, 'some config.json')
expect(url).to.equal('http://localhost:8000')
Our integration test is much the same:
const url = getServerURL(fs.readFileSync, './test/integration/qa-config.json')
Instead of our stub, it’s just the real FileSystem module using the real readFileSync
method.
Conclusions
Dependency Injection, specifically class constructor injection, is a technique used in Object Oriented Programming to make the classes configurable, and easier to test. Any class dependency that does some kind of side effect that could lessen your class’ functional determinism, you make that a dependency so you can test the more pure code in your class. In Functional Programming, you can use the same technique by passing those module or function dependencies as parameters to your function, achieving the same goals.
This isn’t true for all functional languages, though. In Elm, for example, this technique isn’t used because Elm does not have side effects since all functions are pure. In ReScript, however, you would because while it’s Functional, it still has the exact same side effect model as JavaScript because it compiles to JavaScript.