Skip to content

Instantly share code, notes, and snippets.

@davewasmer
Last active October 25, 2017 17:38
Show Gist options
  • Save davewasmer/096643fefdc783f228a34164052c51bb to your computer and use it in GitHub Desktop.
Save davewasmer/096643fefdc783f228a34164052c51bb to your computer and use it in GitHub Desktop.

The goal

I usually find it help to start with the API we want, and work backwards. For models, we want something like:

import Post from '../models/post';

class CreatePost extends Action {
  respond({ body }) {
    let post = new Post();   // <- Just a simple `new Post()`
    return post.save();
  }
}

// or ...

import Post from '../models/post';

class ListEditablePosts extends Action {
  respond() {
    return Post.editableBy(this.currentUser);   // <- Static query methods
  }
}

Right now, that's not possible with Denali. The main reason is because of our test framework, Ava, and how we do dependency injection.

The problem

Ava runs tests concurrently. This means that tests cannot rely on any shared state, because they execute at the same time. If they do share state, you get very subtle and bizarre test failues, because test B modifies things that test A assumes don't change. A concrete example:

// container is shared across tests:

test('A', async (t) => {
  container.mock('orm-adapter', {
    findAll() { return [ 1, 2, 3 ]; }
  });

  let results = await Post.all();
  t.equal(results.length, 3);
});

test('B', async (t) => {
  container.mock('orm-adapter', {
    findAll() { return []; }
  });

  let results = await Post.all();
  t.equal(results.length, 0);
});

In this example, assume Post.all() method needs to talk to the 'orm-adapter'. It gets a reference to that orm adapter via dependency injection through the shared container.

Notice that the two tests provide different mocks for the orm adapter, to test different behaviors. However, both tests use the same Post class.

Remeber that Ava runs tests concurrently. So when test A runs, it sets up it's mock of orm-adapter, then invokes Post.all(). But meanwhile, test B has started, and it mocks the orm-adapter as well - overwriting test B mocks.

The result? Test B fails saying results.length is zero - because it used the mock from test A!

The fix? We need a separate container for each test (easy enough), and we can't allow the static, shared Post class to see it, because there's only one Post class and it therefore can't be used concurrently, because it wouldn't know which container to use.

This is why, right now, you can't simply import Post and use it - because you'd be in danger of leaking state through the shared use of the static class. So we currently hide all the static classes behind the this.db service, which handles them appropriately.

This sucks

Now we don't get the APIs we want, as mentioned above (i.e. Post.all(), Post.editableBy(), new Post() etc). So how do we fix this while avoiding the issues described above?

Let's start from scratch, putting aside the pitfalls from above. All those APIs we want depend on being able to use the static Post class - so we know we at least need to get a reference to Post somehow. So where can it come from?

In JS classes like Denali uses, we can get references to variables from 4 places really:

1. Instance context (a.k.a. this)

We could do something like this.models.Post, but that means lots of repetitive code like let { Post } = this.models in every single action - kinda sucks. That's framework code for the sake of the framework rather than your app logic. This doesn't seem much better than bolting on to the existing this.db API.

2. Method parameters

This feels like a non starter - all the boilerplate of instance methods, plus now we're polluting method signatures with seemingly unrelated arguments as well.

3. Globals

Globals run counter to the general Node ethos, and tend to lead to "where the heck did this Post variable come from", making the application harder to reason about and debug. Also, globals mean shared state - which as we saw above is problematic.

4. Imports

This avoids the drawbacks from above, and fits in well with existing tooling. But it suffers from the pitfalls we encountered above.

The solution

The single-tenant solution

One solution might be to ditch support for concurrent application instances in memory (i.e. ditch Ava, or at least force it serial). That way, the shared state is okay - because there will only ever be one container in memory, one set of mocks, etc. I call this the "single-tenant" solution because it means only one Denali app can exist in an Node process at a time.

The big drawback here is that we give up a really awesome feature for large apps - automatically concurrent test suites. Can we do better?

The closure-global solution

Honestly, we can't really do better than the single-tenant solution if we stay inside the constraints of JS. But - we have a build step with Denali. We can go beyond JS.

I call this solution the "closure-global" solution, because we basically create a pseudo global scope using closures added at build time.

We would use a bundler (potentially something like Rollup), which would combine the entire app & all it's dependencies into a single bundle.js file. Then, we simply wrap the entire file in a function definition. This turns the entire source code into a factory.

How it works

Our problems with import Post stem from the fact that everyone gets the exact same Post, which means they share state - our big no-no with Ava. If we want to keep Ava's concurrent tests, we need a different Post per application instance.

One approach might be to hijack Node's require (because it's the source of our shared state - it caches modules and returns the cached result). We could re-evaluate the Post module every time it's required.

But this won't work for us, because within an application instance, we do want the Post model to be shared. And since require() has no knowledge of which application instance we care about, it's impossible for it to know whether to deliver a shared copy or a fresh copy.

By turning the entire application source into a factory, we are effectively bypassing Node's module caching when loading the app from outside it's own source code (i.e. from a test, or the index.js entry point). But inside the application source code, we would effectively duplicate Node's caching behavior with our bundler. We can create fresh copies of all the class definitions anytime we need it (i.e. per test) by simply running the factory function to generate fresh definitions of the entire application. This lets us freely share state across static class definitions within the application, but our tests should work fine, because they each will have a different copy of Post to work with.

Basically, we're "stepping down" the global scope via a "pseudo-global" scope (the factory function closure's scope) So when the framework kicks off, we'd eagerly load everything through the container, injecting the static class definitions with a container reference that is unique to that closure, which gets us our Post.all(), new Post(), etc., with no worries about sharing state.

Extensions

Ditch main-dir

One side effect of this bundle step - we could ditch main-dir and simply intercept/rewrite the lookup paths as we bundle. This would be nice since we can avoid the annoying (albeit rare) footgun that running Denali requires importing main-dir first.

More peformant injections

By moving the container to this pseudo global scope, we could move container injections out of the class definitions and into the module definitions:

class CreatePost extends Action {
  mailer = inject('service:mailer');
  respond() {
    this.mailer.send();
  }
}

Could become:

const mailer = lookup('service:mailer');
class CreatePost extends Action {
  respond() {
    mailer.send();
  }
}

This is probably better for performance (if we can avoid injecting every new instance and instead rely on the module closure scope, we don't need to scan every new instance for injections). It's a one time startup cost as each module is evaluated vs. per instance runtime cost.

Ditching inject() altogether

If we wanted to, we could take this one step further and open up a lot of cool API improvements too, like getting rid of the inject/lookup helper entirely. We could do this by hijacking require() in the closure scope and have it check the container first:

import mailer from '../services/mailer'

turns into:

const mailer = require('../services/mailer').default;

But the require() there would actually be Denali's require:

function require(path) {
  if (shouldResolveFromContainer(path)) {
    container.lookup(specifierFromPath(path));
  } else {
    originalRequire(path);
  }
}

This would let us do stuff like:

import mailer from '../services/mailer';

class CreatePost extends Action {
  respond() {
    mailer.send(...);
  }
}

But we would still retain all the DI goodness for testing:

test = unitTest('action:posts/create', {
  'services:mailer': mailerMock
});

test('sends mail', async (t) => {
  let action = t.subject;
  action.respond();
  t.equals(mailerMock.sentEmails.length, 1);
});

The downsides being that (a) this would probably break types, since TS would think that import '../services/mailer' is the static class, but it would actually be the singleton, and (b) more magic (hijacking require is unexpected and surprising).

Downsides

Magic

Your built code would look different than your source code:

  • one big file for all your Denali / containered code
  • the factory wrapper + some container managment would suddenly appear in compiled output

Scope

This could be a big change, depending on how difficult the hypothetical bundler is to implement.

Memory usage

@knownasilya pointed out that this might spike memory usage beyond CI limits during tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment