Using Ramda as a Dependency Injector

Ramda is quickly becoming an indispensible part of my node projects. Lodash is more accessible and beginner-friendly, but ramda is far more powerful and expressive once you wrap your mind around it. In this article, I’ll take a look at the applySpec() function and how it can replace dependency injectors like wagner.

The fundamental idea of dependency injection is to separate out business logic
and service initialization. For example, let’s say you have some code that
runs a query against MongoDB:

const mongodb = require(‘mongodb’);

let db;

function getMongo() {
if (db) {
return db;
}
db = mongodb.MongoClient.connect(‘mongodb://localhost:27017/test’);
return db;
}

module.exports = function(id) {
return getMongo().
then(db => db.collection(‘User’).findOne({ _id: id }));
};

Node.js beginners tend to write code that differs only superficially from the
tangled mess above. Little refactors like putting the getMongo() function
in a separate file, getting the connection string from environment variables,
and adding error handling don’t help the fundamental issue that your query is
tied one-to-one to a mongodb database handle. A better approach is something
like this:

module.exports = db => function getUser(id) {
return db.collection(‘User’).findOne({ _id: id });
};

This way, your database handle is completely decoupled from the query you run.
You can initialize one or many database handles and still use the same
business logic.

This is particularly important for MongoDB, which
limits you to one operation per open socket at a time. In other words, unless you tweak the pool size in the
Node.js driver, MongoDB will only process up to 5 operations in parallel per
database handle.
This can be bad if you have a lot of fast operations queued up behind a few
very slow operations, like if a few heavy queries from your admin dashboard
are blocking user logins. Decoupling initialization from business logic makes
it easy to have separate database handles for queries you expect to be slow.

Dependency injectors are powerful tools for breaking code up into services
that depend on each other.

function db(config) {

}

function logger() {

}

function queryBuilder(db, config, logger) {

}

For example, in the above code, a dependency injector like wagner would be smart enough to walk
the graph of dependencies and see that in order to initialize queryBuilder,
it needs to initialize config and logger first, then db, and then
queryBuilder. A DI tool lets you separate logic from initialization in a
convenient way where you don’t really have to think about where the services
you depend on come from.

Unfortunately, convenience is a false god. Driving your car to the grocery
store 1 mile away rather than walking is convenient, but it’s also
bad for your bank account, your health, and your waistline. Similarly, DI
tools often lead to building weak abstractions and “service soup.” Each
individual service may be easy understand on it’s own, but the high-level
structure is hard to understand because there’s no effective way to group
services together. When everything in your codebase is a “service”, the term
“service” becomes meaningless.

Enter ramda’s applySpec() function. The general idea of applySpec() is that,
given an object whose keys are functions and some parameters, it calls each
function in the object and returns a new object whose keys are the return
values of each function. In code,

const stringifyMilliseconds = {
milliseconds: x => `${x}ms`,
seconds: {
exact: x => `${x / 1000}s`,
rounded: x => `${Math.round(x / 1000)}s`
}
};

require(‘ramda’).applySpec(stringifyMilliseconds)(1234);

In other words, applySpec() lets you execute a bunch of functions
with the same arguments and organize the return values. How does this help
with DI? Well, let’s say that you have a bunch of functions like the user
query you had before.

module.exports = {
getUser: db => function(id) {
return db.collection(‘User’).findOne({ _id: id });
},
updateUser: db => function(id, update) {
return db.collection(‘User’).updateOne({ _id: id }, { $set: update });
},
deleteUser: db => function(id) {
return db.collection(‘User’).deleteOne({ _id: id }).then(res => {
if (res.n === 0) {
throw new Error(‘User not found’);
}
return res;
})
}
};

These functions are nice and DI-friendly: they don’t rely on any one mechanism
for initializing the database handle. They also have a couple things in
common: they all take exactly one parameter, a database handle. This makes it
easy to initialize all of them in a single function call with applySpec().

co(function * () {
const db = yield mongodb.MongoClient.connect(process.env.MONGO_URL);
const User = applySpec(require(‘./user’))(db);

return User;
});

This way, User is now an organized collection of functions and the db
dependency is closed over in a way that’s transparent to clients using the
User module. You can also now think of the functions in User as a
distinct group as opposed to a collection of nebulous services.
For example, since these functions all return promises, it’s easier to
instrument common error handling:

const { applySpec, map } = require(‘ramda’);

const errorHandler = fn => function() {
return fn.apply(null, arguments).catch(error => {
console.error(error);
});
};

const User = map(errorHandler, applySpec(require(‘./user’))(db));

This is just the tip of the iceberg with ramda. You can get an equivalent
function to applySpec() with lodash using recursive _.map(), but, as
is often the case with ramda, you get a more powerful and expressive
abstraction abstraction out of the box in applySpec(). In particular,
applySpec() is a great DI replacement because it forces you to build better
abstractions and avoid service soup by not letting you rely on the tool
to resolve your dependency graph.

Leave a Reply

Your email address will not be published.


*