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.
What's Dependency Injection About?
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.
Where DI Goes Wrong
Dependency injectors are powerful tools for breaking code up into services that depend on each other.
// db service
function db(config) {
// initialize db
}
// logger service
function logger() {
// initialize logger
}
// query service
function queryBuilder(db, config, logger) {
// initialize query builder
}
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.
How Ramda Helps
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`
}
};
/**
* {
* milliseconds: '1234ms',
* seconds: { exact: '1.234s', rounded: '1s' }
* }
*/
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);
// User is now a collection of functions that have access to the `db` handle.
// In other words, you can do 'yield User.getUser(id)'
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);
});
};
// Apply 'errorHandler' to every function so you can '.catch()' the
// promise returned from every function
const User = map(errorHandler, applySpec(require('./user'))(db));
Moving On
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.