Fun with Stamps. Episode 2. Dependency injection in FP

Vasyl Boroviak
6 min readMay 8, 2016

Hello. I’m developer Vasyl Boroviak and welcome to the second episode of Vasyl Boroviak presents Fun with Stamps.

Dependency injection problem in FP

Doing backend JavaScript for a few years now I always struggled with unit testing the business logic I write.

Taking into account that I’m trying to code using pure functions (or even procedural programming) the dependency injection is becoming a real problem.

Here is the typical node.js business logic code:

import boom from 'boom';
import userDB from './user-db';
import folderDB from './folder-db';
import userAccess from './user-access-service-client';
import fileStorage from './local-hard-drive-storage';

export default function storeDocument(
{ userID, folderLocation, fileStream }
) {
const user = await userDB.findOne(userID);
const folder = await folderDB.findOne(folderLocation);
const allowed = await userAccess.canWrite(user, folder);

if (!allowed) return throw boom.forbidden();

return await fileStorage.save(fileStream, { user, folder });
}

This code is doing the following I/O:

  • read user information from database,
  • read folder information from database,
  • call service to approve access of that user to that folder,
  • save the file to the local hard drive.

Now, you need to unit test the access rights are being checked.

In traditional JavaScript there are only two ways to mock that I/O:

These both approaches are very problematic in business applications. They couple the code to the external environment. A light change to it will break your tests, — fragile tests problem. Typical business logic code is highly prone to change. Trust me, I’ve been there. The hours spent on fixing various tests used to be the majority of my day to day work.

Here is what I came up with as a dependency injection solution — one more argument:

import boom from 'boom';
import userDB from './user-db';
import folderDB from './folder-db';
import userAccess from './user-access-service-client';
import fileStorage from './local-hard-drive-storage';

const defaults = { userDB, folderDB, userAccess, fileStorage };

export default function storeDocument(
{ userID, folderLocation, fileStream },
dependencies // injecting dependencies!!!
) {
const { userDB, folderDB, userAccess, fileStorage } =
{ ...defaults, ...dependencies }; // using them!!!

const user = await userDB.findOne(userID);
const folder = await folderDB.findOne(folderLocation);
const allowed = await userAccess.canWrite(user, folder);

if (!allowed) return throw boom.forbidden();

return await fileStorage.save(fileStream, { user, folder });
}

The only API change is the new argument to the storeDocument function. If you omit the argument then the real I/O will do its job. But if you supply the argument then you can mock everything. Amazing!

After that my unit testing became simply perfect… I wish I could say that. The reality is more complex than a single function.

In real world the first three I/O operations are abstracted away to a separate reusable function (like a private NPM module), which is used in a few dozen other places. E.g. this resolveUserAccess:

import boom from 'boom';
import userDB from './user-db';
import folderDB from './folder-db';
import userAccess from './user-access-service-client';

export default function resolveUserAccess(
{ userID, folderLocation },
dependencies
) {
const { userDB, folderDB, userAccess, fileStorage } =
{ ...defaults, ...dependencies };

const user = await userDB.findOne(userID);
const folder = await folderDB.findOne(folderLocation);
const allowed = await userAccess.canWrite(user, folder);

if (!allowed) return throw boom.forbidden();

return { user, folder };
}

The storeDocument depends on the resolveUserAccess actually.

import resolveUserAccess from './resolve-user-access'; // dependency
import fileStorage from './local-hard-drive-storage';

const defaults = { resolveUserAccess, fileStorage };

export default function storeDocument(
{ userID, folderLocation, fileStream },
dependencies
) {
const { resolveUserAccess, fileStorage } =
{ ...defaults, ...dependencies };

const { user, folder } = await resolveUserAccess(
{ userID, folderLocation },
dependencies // pass the dependencies further down the stack
);

return await fileStorage.save(fileStream, { user, folder });
}

There is no problems with this function. It’s just one more argument to each of your functions. Not a big deal. Or big?

If you don’t want to pollute your API with additional argument you can use the standard JavaScript feature — bind the function context and use dependencies through this.

import resolveUserAccess from './resolve-user-access'; 
import fileStorage from './local-hard-drive-storage';

const defaults = { resolveUserAccess, fileStorage };

export default function storeDocument(
{ userID, folderLocation, fileStream }
) {
const { resolveUserAccess, fileStorage } =
{ ...defaults, ...this }; // this!!!

const { user, folder } = await resolveUserAccess({ userID, folderLocation });

return await fileStorage.save(fileStream, { user, folder });
}.bind(defaults); // binding to the `default` context!!!

Voila! The function signature is pure again. Unit tests can pass dependencies using .apply, .call, .or .bind like so:

await storeDocument.call(
mockDependencies,
{ userID, folderLocation, fileStream }
)

My congratulations!

We have just reinvented stamps

In this blog post by Christopher Giffard the people came to the similar conclusion about dependency injection.

Eventually, this same colleague suggested we try a slightly quirky method: a factory based “implementation” with a second “interface” file which required dependencies, injected them, and exported the constructed dependency.

In other words, they also think that factory functions are the right way to do it in JavaScript.

Stamps to the rescue

Stamps take away these default and Object.assign we had to use inside each of our functions. You just use every dependency by appending this to it.

resolveUserAccess implementation using stamps:

import boom from 'boom';
import userDB from './user-db';
import folderDB from './folder-db';
import userAccess from './user-access-service-client';
import compose from 'stamp-specification';

function resolveUserAccess({ userID, folderLocation }) {
const user = await this.userDB.findOne(userID); // this!!!
const folder = await this.folderDB.findOne(folderLocation); // this!!!
const allowed = await this.userAccess.canWrite(user, folder) // this!!!

if (!allowed) return throw boom.forbidden();
return { user, folder };
}

export const ResolveUserAccess = compose({ // compose!
properties: { userDB, folderDB, userAccess }, // defaults!!!
methods: { resolveUserAccess }
});

export default resolveUserAccess.bind(ResolveUserAccess());

storeDocument implementation using stamps:

import { ResolveUserAccess } from './resolve-user-access'; 
import fileStorage from './local-hard-drive-storage';

function storeDocument({ userID, folderLocation, fileStream }) {
const { user, folder } =
await this.resolveUserAccess({userID, folderLocation}) // this!!!

return await this.fileStorage.save(fileStream, { user, folder }) // this!!!
}

export const StoreDocument = ResolveUserAccess.compose({ // compose!
properties: { fileStorage }, // defaults!!! merge with above defaults
methods: { storeDocument }
});

export default storeDocument.bind(StoreDocument());

Here is how you use it in production. Nothing fancy, typical pure function:

import storeDocument from './store-document';

await storeDocument({
userID: '42',
folderLocation: '~',
fileStream: request
});

Another way to use it:

import { StoreDocumen t} from './store-document';

await StoreDocument().storeDocument({
userID: '42',
folderLocation: '~',
fileStream: request
});

And here is the unit test:

import { StoreDocument } from './store-document';

const MockedStoreDocument = StoreDocument.compose({
properties: mockDependencies // I/O mocks!!!
});

await MockedStoreDocument().storeDocument({
userID: '42',
folderLocation: '~',
fileStream: request
});

Have you noticed what have just happened?

  • The pure functions resolveUserAccess and storeDocument are still pure.
  • The dependencies can be easily injected. But yet, no any additional arguments passed to the functions.
  • Both ResolveUserAccess and StoreDocument stamps can be composed with any other stamp.
  • You can choose to test storeDocument only, or the resolveUserAccess and storeDocument together.

Have fun with stamps!

--

--