Dependency Injection with Closures

Dependency injection (DI) is often seen as a complicated Real Enterprise™ technique involving lots of decorators, annotations, magic, and complexity. But that doesn't have to be the case. This article is about a simpler way of doing DI in a variety of situations, especially when you need to interact with frameworks that don't want to play nice.

There's nothing new about the idea of dependency injection, or DI. It's a design pattern that allows us to prevent different components from becoming too tightly bound together. Using dependency injection, we reduce the extent to which modules are dependent on each other's intrinsic implementation, and make it easier to share implementations. This can make code easier to test, easier to maintain, and easier to update as new features or changes are needed.

Why DI?

Dependency injection is an extension of the idea that a unit of code should know chiefly about its own responsibilities, and not about how other units of code operate (i.e. encapsulation). A class or function for handling business logic related to users will probably need to access a database to load those users, but we don't want it to contain all the instructions for creating that database connection, loading the correct tables, parsing the data, etc. Ideally, the user logic just needs to know that users can be loaded from somewhere, and that's about it.

More specifically, DI says that a class or function that depends on some other service or unit (say, a UserRepository service) should not construct that dependency itself. Instead, the dependency should be "injected" — typically passed as a parameter to a function or class constructor.

DI makes our code slightly more complex (with new parameters and more complex function signatures), but it also a lot more flexible. For example:

  • Testing becomes a lot easier. Rather than having to monkeypatch imports or deeply mock classes, we can just construct a fake version of the dependency, and pass it to the function or class we want to test.
  • It's easier to configure services or objects based on different environments and contexts. Configuration is just another thing that can be injected, which means that it's easier to, say, make requests to different URLs in development and production. Just construct a different version of the configuration for different environments, and let it get injected around.

It's important to remember that DI is about passing dependencies as parameters. Many frameworks offer various mechanisms for automatically passing these parameters around using decorators, reflection, annotations, and other forms of dark magic — and when there's a lot going on, this can be very useful — but it's not necessary, and the same effect can be achieved in much less code.

Here's an example route definition in Fastify:

import Fastify from 'fastify'
const fastify = Fastify();

fastify.get("/products/:productId", async (request, reply) => {
  // ... TODO: get product cost from a database somewhere
  return reply.send({ id: request.params.productId, value: "???" });
});

As discussed above, dependency injection is about passing parameters to functions. But in the example here, that's difficult, because the handler function parameters are fixed, and we aren't the ones calling the functions — here, Fastify will call the handler functions with the request and reply arguments. So how do we get back to DI from here? One answer is closures.

A closure is a function that captures the scope it is defined in. Put another way, if you have a function with a bunch of variables defined in it, then you write a closure inside that function, then that closure has access to its own arguments, plus the variables defined in the parent function.

This can be used as a tool for dependency injection. If we inject out dependencies into the outer scope, and then define the handler functions inside that scope, then they will have access to the dependencies without us having to change their signature at all.

Here is the example from before, but now using closures:

import Fastify from 'fastify'

export function buildServer(productStore: ProductStore): Fastify {
  const fastify = Fastify();

  fastify.get("/products/:productId", async (request, reply) => {
    const product = await productStore.load(request.params.productId);
    return reply.send({ id: request.params.productId, value: product.value });
  });

  return fastify;
}

const productStore = new ProductStore(db);
const app = buildServer(productStore);

The handlers themselves look identical (because in Javascript, all functions are automatically closures, so we don't need to change anything much there), but instead of defining them at the top level, we define them inside a buildServer function. We can pass all the dependencies we want to this function, and then use them inside the handler functions.

Here, I've constructed the whole fastify object inside a single function, but in practice this can be split up so that different routes are added by different functions, or whatever other delineation makes sense for your application.

And what might this look like to test? Well, if we're testing just the routes, we don't want to use the real ProductStore object, so we can just pass in a fake value - a test double, or some other form of mock object that will return what we want it to return. That might look something like this:

test("returns product ID", async () => {
  // create a mock product store
  // (here using objects and type assertions, other options are available).
  const productStore = {} as ProductStore;
  productStore.load = async () => ({ value: 123 } as Product);
  
  // create our server object
  const server = buildServer(productStore);

  // perform the test
  // (N.B. `inject` is a fastify-specific method for passing a test request to a function)
  const response = await server.inject({ method: "GET", url: "/products/testid" });
  
  // assert the results
  expect(response.json()).toEqual({ id: "testid", value: 123 });
});

Conclusion

Dependency injection is a flexible way of keeping code clean and maintainable. It allows for better separation of concerns, and makes code easier to test and extend.

By using the closure technique here, we can apply DI even to situations where we don't have full control over the parameters that a function might take. Given the power and utility of DI, this gives us more chances to make use of this technique in different contexts and with different frameworks.