Skip to content

Service Locator

A well-structured application architecture is essential for building maintainable, scalable Node.js applications. This guide outlines our approach to organizing dependencies and implementing the Service Locator pattern in our Node.js applications.

Service Locator Pattern#

The Service Locator pattern provides a centralized registry of services, making dependencies available throughout the application while maintaining loose coupling between components.

Core Implementation#

Create a service locator module to manage application-wide dependencies. Register the services when creating the service locator.

// service-locator.js
import ServiceLocator from "dislocator";
import registerDatabaseService from "./services/database.js";
import registerConfigService from "./services/config.js";
import registerDalService from "./services/dal.js";

export default function createServiceLocator() {
  const serviceLocator = new ServiceLocator();

  serviceLocator
    .use(registerConfigService)
    .use(registerDalService)
    .register("db", registerDatabaseService(serviceLocator));

  return serviceLocator;
}

This service locator can then be used to create the app:

import express from "express";
import { errorHandler } from "./errorHandler.js";
import scanQueuesHandler from "./handlers/scanQueues.js";

export default function appFactory(serviceLocator) {
  const db = serviceLocator.db;

  const app = express();
  app.use(express.json());

  app.use(
    "/api/v1/scan",
    scanQueuesHandler(serviceLocator),
  );

  app.use(errorHandler());

  return app;
}

Using Services in Controllers#

Access services from controllers and handlers through the service locator. In the following example, the DAL service is accessed through the service locator for the handler to call the corresponding business logic.

import express from "express";
import httpErrors from "http-errors";
import { isValidHttpURL } from "../utils/isValidHttpURL.js";

export default function scanQueuesHandler(serviceLocator) {
  const app = express.Router();
  const dal = serviceLocator.dal;

  app.post("/", async (req, res) => {
    if (!req.body || typeof req.body !== "object") {
      throw new httpErrors.BadRequest("Invalid request body");
    }
    const { url } = req.body;
    if (!url) {
      throw new httpErrors.BadRequest("URL is required");
    }
    if (typeof url !== "string") {
      throw new httpErrors.BadRequest("URL must be a string");
    }
    if (!isValidHttpURL(url)) {
      throw new httpErrors.BadRequest("Invalid URL format");
    }
    const { job_id } = await dal.scanQueue.createScan({
      url,
      type: "single",
    });
    res.status(201).json({ job_id });
  });

  return app;
}

Adding Your Own Services#

The service locator is not limited to the database and DAL — register any shared dependency that your application needs.

A service can be registered in two ways:

Eager — the value is created immediately at registration time:

serviceLocator.register("db", registerDatabaseService(serviceLocator));

Lazy — the value is created on first access by passing a factory function:

serviceLocator.register("dal", () => new DataAccessLayer(serviceLocator.get("db")));

Use lazy registration when a service depends on another service that may not be registered yet, or when instantiation is expensive and the service may not be needed in every code path.

Example: Adding a Redis Cache Service#

Suppose your application needs a cache. First, install the Redis client:

npm install redis

Create the cache class:

lib/Cache.js

import { createClient } from "redis";

export default class Cache {
  constructor(cacheConfig) {
    this.client = createClient({ url: cacheConfig.url });
  }

  async connect() {
    await this.client.connect();
  }

  async get(key) {
    const value = await this.client.get(key);
    return value ? JSON.parse(value) : null;
  }

  async set(key, value, ttlSeconds = 60) {
    await this.client.set(key, JSON.stringify(value), { EX: ttlSeconds });
  }

  async del(key) {
    await this.client.del(key);
  }

  async close() {
    await this.client.quit();
  }
}

Create the service that registers it:

lib/services/cache.js

import Cache from "../Cache.js";

export default function registerCacheService({ config }) {
  return new Cache(config.cache);
}

Add the required configuration to your config service:

cache: {
  url: getEnv("REDIS_URL", "redis://127.0.0.1:6379"),
},

Then register it in the service locator:

import ServiceLocator from "dislocator";
import registerConfigService from "./services/config.js";
import registerDalService from "./services/dal.js";
import registerDatabaseService from "./services/database.js";
import registerCacheService from "./services/cache.js";

export default function createServiceLocator() {
  const serviceLocator = new ServiceLocator();

  serviceLocator
    .use(registerConfigService)
    .use(registerDalService)
    .register("db", registerDatabaseService(serviceLocator))
    .register("cache", registerCacheService(serviceLocator));

  return serviceLocator;
}

Connect to Redis during application bootstrap in server.js:

const cache = serviceLocator.cache;
await cache.connect();
console.log("Redis: connected");

And close the connection during shutdown:

console.log("Closing Redis connection...");
await cache.close();

The cache is now accessible anywhere via serviceLocator.cache:

// Store a value with a 5-minute TTL
await serviceLocator.cache.set("user:123", { name: "Alice" }, 300);

// Retrieve it
const user = await serviceLocator.cache.get("user:123");

To verify locally, start a Redis container with docker run -d --name redis-dev -p 6379:6379 redis:7 and set REDIS_URL=redis://127.0.0.1:6379 in your tools/virtual-env.sh.