Adapting existing code for Intravenous

I'm using Intravenous more and more to implement the IOC pattern using Node at work. One of the issues with it however is that everything that you use with it either has to be:

  1. An ES5 "class" with an $inject property (an array that allows Intravenous to reflect on your constructor arguments for the constructor. By the way, Intravenous doesn't do ES6. Have a look at mainliner it you want to do native ES6.
  2. A plain old JavaScript object.

In order to support the normal IOC things like lifetime and constructor injection you need your code to be a ES5 class as per option 1. 9 times out of 10 this is what you so therefore it forces you to write your code exactly like it expects which isn't really the way things work in the real world. I mean, who on earth is going to publish a module exporting a class with a $inject property and even if they did, you would probably just need to over write it with different identifiers anyway unless you had a very, very controlled environment.

So, my implementation uses a pattern that looks a bit like this:

import Assert from "assert";
import Bacon from "bacon";
    
export default class BaconWrapper {
      
  constructor(logger) {

    Assert(logger, "logger is required");
    this.bacon_ = new Bacon(logger);
  }

  eatBacon(...args) {

    return this.bacon_.eatBacon(...args);
  }
}

BaconWrapper.$inject = ["logger"];

and after registering BaconWrapper in my container, it works a treat. The problem with this approach of course is that it is:

  1. Incredibly manual and tedious.
  2. Every time you change an interface the a wrapper needs to change too!

Also, all of this manual code gives you a tonne of new tests you have to write. Yuck.

I figured, especially as this is JavaScript, there must be a better way - and here it is.

function adaptToInjectable(ctrArgNames, ctrArgInjectNames, objectFactory, proxiedKeys) {

  const f = function () {

    // Assert that constructor is new'ed
    Assert(this && this.constructor === f, "constructor must be called with new");

    // Assert arguments
    for (let x = 0; x < ctrArgNames.length; x += 1) {
      Assert(arguments[x.toString()], `${ctrArgNames[x]} is required`);
    }

    // Constructor wrapper object
    const args = Array.from(arguments);
    this.wrappedObject_ = objectFactory.apply(null, args);

    // Proxy each key in proxiedKeys
    for (const key of proxiedKeys) {
      const actor = this.wrappedObject_[key];

      // Something is pertty wrong if we are proxying a key with an undefined value
      Assert(typeof actor !== "undefined", `proxiedKey ${key} does not exist`);

      // If the property is a function then bind it back to the wrappedObject,
      // otherwise just set one to the other
      if (typeof actor === "function") {
        this[key] = actor.bind(this.wrappedObject_);
      } else {
        this[key] = actor;
      }
    }
  }

  // Add $inject
  f.$inject = ctrArgInjectNames;

  return f;
}

So, as you can see this adaptor allows you to wrap a piece of arbitrary code (it doesn't even need to be a class) and with just a small piece of code get it up to a state that is easily injectable into a container.

So, given the following service

class CakeService {

	constructor(logger, cakeType) {

    Assert(logger);
    Assert(cakeType);
    this.logger_ = logger;
    this.cakeType_ = cakeType;
  }

  eat() {

    this.logger_.info(`Nom, nom, nom - I love ${this.cakeType_} cake`);
  }

  get cakeType() {

    return this.cakeType_
  }
}

We can adapt this using the following code

const sut = adaptToInjectable(
  ["logger"],
  ["myLogger"],
  (logger) => new CakeService(logger, "chocolate"),
  ["eat", "cakeType"]
);

And all of the following assertions should work on the class

  Assert.throws(() => sut(), /^AssertionError: constructor must be called with new$/,
    "Should assert that ctx called with new");
  Assert.throws(() => new sut(), /^AssertionError: logger is required$/, "Logger is a required argument");
  Assert.deepEqual(sut.$inject, ["myLogger"], "$inject is not set to required arguments");

  const mockLogger = {
    "info": Sinon.stub()
  };
  const actual = new sut(mockLogger);

  Assert(actual.cakeType, ".cakeType should be defined");
  Assert.strictEqual(actual.cakeType, "chocolate");

  Assert(actual.eat, ".eat should be defined");
  Assert(typeof actual.eat === "function", ".eat should be a function");
  Assert.doesNotThrow(() => actual.eat(), ".eat should not throw");
  Sinon.assert.calledWith(mockLogger.info);
  Sinon.assert.calledWithExactly(mockLogger.info, "Nom, nom, nom - I love chocolate cake");

I hope that this helps.