Egor Panok

Full Stack JavaScript Developer

Dependency Injection in Node.JS, Part 2 - Injected Service Locator

October 30, 2018 - 8 min read

Dependency Injection in Node.JS, Part 2 - Injected Service Locator

In the previous article, we reviewed the basic DI pattern. Another pattern with very similar purpose is Service Locator. The essence of it is to have a central registry where we put the modules in and then get them from. Here’re we’re going to inspect the most flexible version of Service Locator pattern - Injected Service Locator (there are also the following modifications of the SL pattern: hardcoded dependency on service locator, global service locator, which are not that flexible and not considered as best practices, hence they’re out of the scope of this article).

Injected Service Locator

The whole point of Injected Service Locator pattern is that we register modules in it and then pass it into other modules through DI. You might agree that it’s a more convenient way of injecting an entire set of dependencies at once compared to providing them one by one.

Injected Service Locator Signature

Remember that the Service Locator is nothing but a registry of components (or modules / dependencies). Each component has its name in the registry. And as you might have figured out we either need to register a new dependency in service locator for a later usage or get it when it’s actually needed. It’s the basic idea. But for now, not to go too deep, not to overwhelm you with the details, let me show only the signature of the Service Locator module. It should be just enough to get a taste of the pattern. And at the and of the article I’ll show you a possible implementation.

Alright, here’s the signature:

  1. register(name, instance) - associates a component name directly with the component instance, actually registers the instantiated dependency. Mostly used for simple dependencies that don’t require other dependencies to be instantiated, such as connection strings and data from your config file.
  2. factory(name, factory) - associates a component name against a component factory. The factory function will be called later by Service Locator itself 0n-demand.
  3. get(name) - retrieves the component by its name. It will be used either outside of our dependency tree or inside of it, or in other words, inside of components, having a link to the Service Locator, to get access to other components through our Service Locator registry.

Use Case

We’re going to examine the same use case as in the previous article. Let me remind real quick. We’re going to create a task tracker app. Every time the app starts along with having task endpoints we’d like to have a test DB created and initialized with random data just for unit testing. Since we have two databases we need to have the two module dependencies trees. Here’s where Injected Service Locator comes to our aid.

Now let’s refactor the modules from the previous article.

Step 1. Refactor Database Connection module

// db-connection.js

const mongoose = require("mongoose")
mongoose.set("useCreateIndex", true)

module.exports = serviceLocator => {
    const dbUrl = serviceLocator.get("dbUrl")

    return mongoose.createConnection(dbUrl, {
        useNewUrlParser: true
    })
}

As you have noticed, our module takes a Service Locator instance as an argument in the factory function. And then takes the dbUrl dependency (connection string to the database) by means of the Serivice Locator’s get() method.

The same approach will continue repeating for the rest of the modules being refactored - we inject our Service Locator and then use it inside to get other modules through it. As you see, the Service Locator acts as a mediator.

Step 2. Refactor Model Creator

// model-creator.js

module.exports = serviceLocator => {
    const connection = serviceLocator.get("connection")

    return {
        create: (modelName, schema) => {
            return connection.model(modelName, schema)
        }
    }
}

Model Creator (a.k.a. Model Builder from the previous article) takes a Service Locator instance as a factory function argument and then access the connection through it. The Model Creator has the create() method to create modules by name and schema.

Step 4. Refactor Modules for Generating and Dropping Tasks

// task-dropper.js

module.exports = serviceLocator => {
    const modelCreator = serviceLocator.get("modelCreator")
    const taskSchema = serviceLocator.get("TaskSchema")
    const TaskModel = modelCreator.create("Task", taskSchema)

    return {
        drop: () => {
            return new Promise((resolve, reject) => {
                TaskModel.deleteMany((err, res) => {
                    if (err) {
                        return reject(err)
                    }

                    return resolve(res)
                })
            })
        }
    }
}

Please note that I’m going to use the random module from the previous article to generate random data.

// task-generator.js

const random = require("./../random")

module.exports = serviceLocator => {
    const generateConfig = serviceLocator.get("generateConfig")
    const n = generateConfig.tasks
    const modelCreator = serviceLocator.get("modelCreator")
    const taskSchema = serviceLocator.get("TaskSchema")
    const TaskModel = modelCreator.create("Task", taskSchema)

    return {
        generate: () => {
            return new Promise((resolve, reject) => {
                let i,
                    task,
                    counter = 0,
                    tasks = []

                for (i = 0; i < n; i++) {
                    task = new TaskModel({
                        name: random.getRandomString(20, 40),
                        description: random.getRandomString(100, 300),
                        completed: random.getRandomInteger(0, 50) > 10
                    })

                    task.save((err, t) => {
                        if (err) {
                            return reject(err)
                        }

                        counter++
                        tasks.push(t)

                        if (counter === n) {
                            return resolve(tasks)
                        }
                    })
                }
            })
        }
    }
}
// data-generator.js
module.exports = serviceLocator => {
    const taskGenerator = serviceLocator.get("taskGenerator")
    const taskDropper = serviceLocator.get("taskDropper")

    return {
        generate: () => {
            // Drop tasks
            taskDropper
                .drop()
                .then(() => {
                    // Generate new tasks
                    return taskGenerator.generate()
                })
                .then(tasks => {
                    for (let i = 0; i < tasks.length; i++) {
                        console.log(
                            `Task name: "${tasks[i].name}". Completed: ${tasks[i].completed}`
                        )
                    }
                })
        }
    }
}

Step 5. Refactor The Entry Point of The App

Now we’re ready to bind it all over. Let’s create two Service Locator instances for our use case and initialize them with dependencies and factories.

// app.js

const express = require("express")
const app = express()
const bodyParser = require("body-parser")
const jsonParser = bodyParser.json()
const config = require("./config")
const port = config.port
const TaskSchema = require("./data-access/schemas/task")

// Service Locator for Test Database
const testServiceLocator = require("./service-locator")()
testServiceLocator.register("dbUrl", config.dbUris.test)
testServiceLocator.register("generateConfig", config.generate)
testServiceLocator.register("TaskSchema", TaskSchema)
testServiceLocator.factory("connection", require("./data-access/db-connection"))
testServiceLocator.factory(
    "modelCreator",
    require("./data-access/model-creator")
)
testServiceLocator.factory("taskDropper", require("./data-gen/task-dropper"))
testServiceLocator.factory(
    "taskGenerator",
    require("./data-gen/task-generator")
)
testServiceLocator.factory(
    "dataGenerator",
    require("./data-gen/data-generator")
)

// Service Locator for Prod Database
const prodServiceLocator = require("./service-locator")()
prodServiceLocator.register("dbUrl", config.dbUris.prod)
prodServiceLocator.register("TaskSchema", TaskSchema)
prodServiceLocator.factory("connection", require("./data-access/db-connection"))
prodServiceLocator.factory(
    "modelCreator",
    require("./data-access/model-creator")
)

// Generate Test DB
testServiceLocator.get("dataGenerator").generate()

// Prod Instances
const modelCreator = prodServiceLocator.get("modelCreator")
const taskSchema = prodServiceLocator.get("TaskSchema")
const Task = modelCreator.create("Task", taskSchema)

app.use(bodyParser.urlencoded({ extended: false })) // get our request parameters
    .use(jsonParser)

app.get("/tasks", (req, res) => {
    Task.find((err, tasks) => {
        if (err) {
            return res.sendStatus(400)
        }

        return res.json(tasks)
    })
})

app.listen(port, () => console.log("Listening on port ", port))

Great we’re done with the use case! Our endpoint is configured to use the production database and at the same time we’ve initialized the test database with random data for unit testing. Now we can use that two Service Locator instances to extend the range of scenarios for endpoints and unit testing.

Now when we saw the practical usage of the Service Locator pattern, let’s get to the possible implementation according to the signature declared earlier.

Service Locator Module

Not to invent the wheel, I’ll use the version of the Service Locator module, picked from this awesome book.

// service-locator.js

module.exports = function() {
    const dependencies = {}
    const factories = {}
    const serviceLocator = {}

    serviceLocator.factory = (name, factory) => {
        factories[name] = factory
    }

    serviceLocator.register = (name, instance) => {
        dependencies[name] = instance
    }

    serviceLocator.get = name => {
        if (!dependencies[name]) {
            const factory = factories[name]
            dependencies[name] = factory && factory(serviceLocator)
            if (!dependencies[name]) {
                throw new Error("Cannot find module: " + name)
            }
        }
        return dependencies[name]
    }

    return serviceLocator
}

So, as we can see, the implementation of the register() and factory() methods is quite straightforward. Just note the realization of the get() method. I can’t omit stressing it with the quote from the book:

It is very important to observe that the module factories are invoked by injecting the current instance of the service locator (serviceLocator). This is the core mechanism of the pattern that allows the dependency graph for our system to be built automatically and on-demand.

The repository to play around Service Locator pattern is available here.

Summary

The Service Locator pattern allows us to pass the dependencies as a set to initialize other components / dependencies. It’s obviously more convenient and simpler than passing the dependencies one by one. The price for it is that it’s harder to identify the relationships between the components as they are resolved at runtime and that our components become dependent on the service locator - in other words, they can’t be reused independently from the service locator.

Credits: Photo by rawpixel on Unsplash


Written by Egor Panok, full stack JavaScript Developer who loves building useful things. Follow him on Twitter

© 2020, Egor Panok, Full Stack JavaScript Developer