Node Dependency Injection.

I have (again) an indiscreet question: what do your dependency injections look like in your Node apps? Have you ever taken the time to think about this topic?

Are you the kind of dev who simply relies on ECMAScript modules to import and export functions and underlying logic across the whole application? Randomly passing data through the seven (or more 😔) parameters of never-ending chained functions? Passing the same information through six layers of abstraction by drilling function parameters? Ending up with an inconsistent mix of functional and whatever-unknown pattern architecture — very classic in JavaScript?

If yes, be reassured: you’re not alone. However, it doesn’t mean that it shouldn’t change…

You don’t have excuse anymore: thanks to Typescript, you can develop beautiful Object-Oriented apps that can rely on proper dependency injections. Once you’ve tasted this, impossible to go back to your spaghettis: I can warranty. Unless they look like those.

Photo by Sofia Ciravegna on Unsplash

What will it bring?

  • You will be able to rely on the robustness of Object-Oriented Programming, benefiting from encapsulationabstractionpolymorphism, and inheritance.
  • You will be able to apply SOLID principles, significantly improving extensibilitytestabilityreadability, and reliability.
  • You will control asynchronous operations at app startup.

Let’s explore one approach to implementing a proper OOP architecture with Node. No need to reinvent the wheel — so let’s take a classic approach (if you’re familiar with Java, you won’t be thrown off).

The approach relies on the following concepts:

The Controller-Service-Repository Pattern

Three layers for three concerns:

  • Controllers: Manage I/O and ensure the link between the app framework (REST, Kafka) and its business logic.
  • Services: Contain the business logic of the app, i.e., business rules and policies.
  • Repositories and clients: Integrate external dependencies like third-party APIs.

You can learn more about it 👉 here.

The Singleton Pattern

Repositories, services, and controllers are classes that should be instantiated once. The instantiations are done in a function named getControllers(), responsible for returning all controllers of the application.

Repositories and clients are instantiated first, then injected into services, which are finally injected into controllers — like nested dolls.

This approach allows you to keep full control over the creation of resources in your app. You can easily manage the synchronous and asynchronous steps required to create controllers.

One Controller = One Route

Controllers are then mapped to API routes through a router. Each controller handles a single route request.

Ok, let’s see in practice what a service looks like with this approach.

We want to enable a new endpoint responsible for returning formatted information about a given customer. The data is fetched from different sources:

  • The database of the backend application, which owns customer data.
  • third-party products API.
  • third-party API that provides merchandising data.

Below are the different classes required to enable the GET /customer/:id/info endpoint.

We can implement

import { Customer, CustomerRegistry } from './registries/CustomerRegistry'
import { ProductCatalogClient } from './clients/ProductCatalogClient'
import { Logger } from './Logger'
import { MerchandisingProfile } from './types'
import { MerchandisingClient } from './clients/MerchandisingClient'

export class CustomerInfo {
  readonly customerRegistry: CustomerRegistry
  readonly productsClient: ProductCatalogClient
  readonly merchandisingClient: MerchandisingClient
  readonly formatter: InfoFormatter
  readonly logger: Logger

  constructor(
    customerRegistry: CustomerRegistry,
    productsClient: ProductCatalogClient,
    merchandisingClient: MerchandisingClient,
    formatter: InfoFormatter,
    logger: Logger,
  ) {
    this.customerRegistry = customerRegistry
    this.productsClient = productsClient
    this.merchandisingClient = merchandisingClient
    this.formatter = formatter
    this.logger = logger
  }

  public async getCustomerInfo(id: string): Promise<string> {
    const customer = await this.customerRegistry.getCustomerById(id)
    const { email } = customer

    const products = await this.productsClient.getCustomerProducts(email)
    const profile = await this.getMerchandisingProfile(customer)

    return this.formatter.formatCustomerInfo(customer, products, profile)
  }

  private async getMerchandisingProfile({
    id,
    email,
    fullname,
  }: Customer): Promise<MerchandisingProfile | undefined> {
    try {
      return await this.merchandisingClient.getUserProfile(fullname, email)
    } catch (e) {
      this.logger.warn(
        `could not get merchandising profile of user with ID = ${id}`
      )
    }
  }
}

Wahoo, this looks so clean 🤩! What’s your secret?

  • Types are the only entities imported through modules.
  • Class dependencies are passed through constructor parameters and then bound to class members, making them directly accessible at runtime inside all methods.
  • Method signatures stay clean since dependencies don’t need to be passed as parameters. Parameters are reserved for context specific to a given request (e.g., the consumer ID in our example).

And here we are! Another way to impress your colleagues and make them fall in love with your code.

Inconsistent dependency injection through function parameters will be a thing of the past.

I hope you’ve enjoyed this article. Feel free to share your constructive feedback so we can discuss this topic further.

And don’t forget: keep your code clean 🧹.

Leave a comment

Your email address will not be published. Required fields are marked *