In building a house, after the architecture of the house has been drawn and accepted, everything built must fit the drawn architecture. A major change along the way may result in a complete tear-down and rebuild from scratch process. Once the building has been built with cement, it cannot be changed to wood. Building physical structures is a rigid process.

Building software is different. Software, unlike physical structures, is expected to be flexible. If the requirements for a software being built changes along the way, it is expected that the changes are implemented without the need to tear down the whole software. New features, bug fixes, a change in the design system or a database to another, all modify software without the need to tear it all down.

What is a dependency?

If we have a function called authenticateGoogleUser in a part of our codebase, and its function is to

  1. verify if a user’s Google OAuth token is valid,
  2. get the user from the application’s database,
  3. create and return a JWT from the user’s database data,

In summary, the function can be written in the manner described below

// authenticate-google-user.js

const verifyGoogleOAuthToken = require("gauth.js");
const fetchUserFromDbOrFail = require("db.js");
const createJwt = require("jwt");

async function authenticateGoogleUser(token) {
  try {
    const { email } = await verifyGoogleOAuthToken(token);

    const user = await fetchUserFromDbOrFail({ email });

    const jwt = createJwt(user.id);

    return jwt;
  } catch (error) {
    // do whatever developers do with errors
  }
}

authenticateGoogleUser depends on verifyGoogleOAuthToken, fetchUserFromDbOrFail and createJwt to function. These three functions that authenticateGoogleUser depends on are called dependencies.

What does Dependency Injection mean?

authenticateGoogleUser has been written in a way that makes it tightly-coupled to its dependencies. This means that if authenticateGoogleUser needs to fetch a user from a different store, or it is decided that authenticateGoogleUser should no longer return a JWT but an OAuth2 token, we may have to tear down authenticateGoogleUser and rewrite it again.

Another problem here is testing authenticateGoogleUser. verifyGoogleOAuthToken and fetchUserFromDbOrFail are both functions that require making a request to services outside of the the code environment. That is - Google OAuth and the database respectively. It will be hard to write a unit test for authenticateGoogleUser without mocking the dependencies using the testing framework in order to prevent making requests to the external services.

Dependency Injection posits that dependencies should be passed (or injected) into their dependents (authenticateGoogleUser in this case) as arguments. How does this look in JavaScript?

Implementing Dependency Injection

In JavaScript, functions can be passed into other functions as arguments the same way that strings and objects can be passed into functions. Using the default parameters feature of JavaScript, the default dependencies of a code component (class or function) can be passed into the function by default.

Let’s refactor authenticateGoogleUser to implement dependency injection.

// authenticate-google-user.js

const verifyGoogleOAuthToken = require("gauth.js");
const fetchUserFromDbOrFail = require("db.js");
const createJwt = require("jwt");

async function authenticateGoogleUser(
  token,
  verifyToken = verifyGoogleOAuthToken,
  fetchUserOrFail = fetchUserFromDbOrFail,
  createToken = createJwt
) {
  try {
    const { email } = await verifyToken(token);

    const user = await fetchUserOrFail({ email });

    const authToken = createToken(user.id);

    return authToken;
  } catch (error) {
    // do whatever developers do with errors
  }
}

From the code snippet above, the dependencies of authenticateGoogleUser have been injected as arguments into it. With the default parameters feature of JavaScript, we can execute authenticateGoogleUser by passing only the token argument. verifyToken, fetchUserOrFail, and createToken will be verifyGoogleOAuthToken, fetchUserFromDbOrFail and createJwt by default respectively.

How does Dependency Injection influence tight-coupling?

The manner in which authenticateGoogleUser has been re-written makes it more flexible. If new software requirements demand that the user should be fetched from another service or another database or an OAuth token, not a JWT should be returned, developers can rewrite authenticateGoogleUser as the code snippet below

// authenticate-google-user.js

const verifyGoogleOAuthToken = require("gauth.js");
const fetchUserFromServiceOrFail = require("users-service.js");
const createOAuthToken = require("oauth.js");

async function authenticateGoogleUser(
  token,
  verifyToken = verifyGoogleOAuthToken,
  fetchUserOrFail = fetchUserFromServiceOrFail,
  createToken = createOAuthToken
) {
  try {
    const { email } = await verifyToken(token);

    const user = await fetchUserOrFail({ email });

    const authToken = createToken(user);

    return authToken;
  } catch (error) {
    // do whatever developers do with errors
  }
}

It is evident how clean and easy this change in specification was implemented. The content of authenticateGoogleUser remains the same but it still does what it is required to do.

How does Dependency Injection influence testing?

In the same vein as reducing tight-coupling, developers can swap the values of verifyToken and fetchUserOrFail for functions that do what they are testing for. They do not have to make requests outside of the code environment to test authenticateGoogleUser or use mocks provided by the testing framework.

test("it returns an auth token", async () => {
  const googleToken = "123456";

  const verifyToken = (token) => {
    return {
      name: "John Doe",
      email: "johndoe@mail.com",
    };
  };

  const fetchUserOrFail = (data) => {
    return {
      id: "abc_123",
      email: "johndoe@mail.com",
    };
  };

  const authToken = await authenticateGoogleUser(
    googleToken,
    verifyToken,
    fetchUserOrFail
  );

  expect(authToken).to.be.a.string();
});

createToken in the test above will have the default value in the authenticateGoogleUser function definition.

Conclusion

Software can be built in such a way that its component parts (functions and classes) are not tightly-coupled and can be easily changed or replaced without breaking changes going undetected. For reducing tight-coupling and making sure that breaking changes are easily detected, two software design principles - dependency injection and testing - can be used respectively.