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.

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus