DEV Community

Emanuel Gustafzon
Emanuel Gustafzon

Posted on

Build your own DI Container in JavaScript.

What we will build

In this chapter we will implement our own DI Container in JavaScript.

We will create a checkout simulation and we are going to use our DI Container to handle the dependency injection.

The services

Here is the service classes and the flow of our application. We have a credit card, a shipping bag and then a class that handles the transaction and one that sends the order.

// Independent service
class CreditCard {
  owner;
  address;
  number;
  cvc;

  set(owner, address, number, cvc) {
    this.owner = owner;
    this.address = address;
    this.number = number;
    this.cvc = cvc;
  }
}
// Independent service
class ShoppingBag {
  items = [];
  total = 0;
  addItem(item) {
    this.items.push(item);
    this.total += item.price;
  }
}
// Dependent on credit card and shopping bag 
class Transaction {
  constructor(creditCard, shoppingBag) {
    this.creditCard = creditCard;
    this.shoppingBag = shoppingBag;
  }
  transfer() {
    console.log(`Transfering ${this.shoppingBag.total} to ${this.creditCard.owner}`);
  }
}

// dependent on credit card and shopping bag
class SendOrder {
  constructor(creditCard, shoppingBag) {
    this.creditCard = creditCard;
    this.shoppingBag = shoppingBag;
  }
  send() {
    console.log(`Sending ${this.shoppingBag.total} to ${this.creditCard.address}`);
  }
}

Enter fullscreen mode Exit fullscreen mode

DI Container skeleton

class DIContainer {
  services = new Map();

  register() {}

  resolve() {}
}
Enter fullscreen mode Exit fullscreen mode

The DI container will have two methods, one to register the services and their dependencies and one to resolve the services and their dependencies.

The services are to be saved in a map with key-value pairs of the name of the service and the service/instance.

Simple registration and resolving

class DIContainer {
  services = new Map();

  register(serviceName, serviceDefinition) {
    this.services.set(serviceName, {
      serviceDefinition
    });
  }

  resolve(serviceName) {
    const service = this.services.get(serviceName);

    if (!service) {
      throw new Error(`Service ${serviceName} not found`);
    }

    return new service.serviceDefinition();
  }
}

Enter fullscreen mode Exit fullscreen mode

Here we simply register a service by adding a key-value pair in the map with the service name and the service definition. The service definition is the class.

This is now like an instance generator.


const container = new DIContainer();

container.register('creditCard', CreditCard);

const creditCard1 = container.resolve('creditCard');

const creditCard2 = container.resolve('creditCard');

creditCard1.set('John Doe', 'Street 12', '0000-0000-0000', '123');

creditCard2.set('Jane Smith', 'Street 2', '0000-0000-0000', '123');

console.log(creditCard1, creditCard2)
Enter fullscreen mode Exit fullscreen mode

Resolve dependencies

An instance generator is not exciting so let’s add dependencies to the services.

These are the steps:

  1. Add an array of all the names of the dependencies.
  2. Resolve all the dependencies by looping over the array and calling the resolve function on all the services.
  3. Add the resolved dependencies into the constructor of the service.
class DIContainer {
  services = new Map();

  register(serviceName, serviceDefinition, dependencies = []) {
    this.services.set(serviceName, {
      serviceDefinition,
      dependencies
    });
  }

  resolve(serviceName) {
    const service = this.services.get(serviceName);

    if (!service) {
      throw new Error(`Service ${serviceName} not found`);
    }

    const resolvedDependencies = service.dependencies.map(dependency => this.resolve(dependency));

    return new service.serviceDefinition(...resolvedDependencies);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now it starts to be interesting. Go ahead and register the services with the dependencies array.

const container = new DIContainer();

container.register('creditCard', CreditCard);
container.register('shoppingBag', ShoppingBag);
container.register('transaction', Transaction, ['creditCard', 'shoppingBag'])

const creditCard= container.resolve('creditCard');
const shoppingBag = container.resolve('shoppingBag');

creditCard.set('John Doe', 'Street 12', '0000-0000-0000', '123');
shoppingBag.addItem({name: 'Apple', price: 1.99});

container.resolve('transaction').transfer();
Enter fullscreen mode Exit fullscreen mode

It works BUT in the console, we get Transfering 0 to undefined.

That's because of scope. the data from the credit card and shopping bag does not persist because our container creates a new instance every time it is called. It has a transient scope.

Add Scope

Let's add scope to our container. by adding a scope parameter in the register function.

  register(serviceName, serviceDefinition, scope, dependencies = []) {
    this.services.set(serviceName, {
      serviceDefinition,
      scope,
      dependencies
    });
  }
Enter fullscreen mode Exit fullscreen mode

Add Transient

Our container already provides transient services but let's add an if statement for when it is transient and add the logic there.

  resolve(serviceName) {
    const service = this.services.get(serviceName);

    if (!service) {
      throw new Error(`Service ${serviceName} not found`);
    }
    if (service.scope === 'transient') {
      const resolvedDependencies = service.dependencies.map(dependency => this.resolve(dependency));

      return new service.serviceDefinition(...resolvedDependencies);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Add Singleton

Let's also add support for singleton services. By doing so check if an instance already exists and if it does return the existing instance and don't create a new one.

In the resister function insiste an instance key in services and set it to null.


register(serviceName, serviceDefinition, scope, dependencies = []) {
    this.services.set(serviceName, {
      serviceDefinition,
      scope,
      dependencies,
      instance: null // add instance property to store the instance of the service
    });
  }
Enter fullscreen mode Exit fullscreen mode

In the resolve method add an if statement for singleton and always return the same instance.

if (service.scope === 'singleton') {
        if (!service.instance) {
            const resolvedDependencies = service.dependencies.map(dependency => this.resolve(dependency));
            service.instance = new service.serviceDefinition(...resolvedDependencies);
        }
        return service.instance;
    }
Enter fullscreen mode Exit fullscreen mode

Now let’s register the credit card and shopping bag as singleton and the transaction as transient.

const container = new DIContainer();

container.register('creditCard', CreditCard, 'singleton');
container.register('shoppingBag', ShoppingBag, 'singleton');
container.register('transaction', Transaction, 'transient',['creditCard', 'shoppingBag'])

const creditCard= container.resolve('creditCard');
const shoppingBag = container.resolve('shoppingBag');

creditCard.set('John Doe', 'Street 12', '0000-0000-0000', '123');
shoppingBag.addItem({name: 'Apple', price: 1.99});

container.resolve('transaction').transfer();
Enter fullscreen mode Exit fullscreen mode

And we get Transfering 1.99 to John Doe in the console.

BUT we are not there yet because the shopping bag and credit card will always have the same instance you can set different values but the instance is the same. So new users cannot add new cards or shopping bags.

watch the code below. We cannot resolve the credit card and shopping bag in different contexts. Let's add 2 cards and 2 bags and you see what I mean.

const creditCard= container.resolve('creditCard');
const shoppingBag = container.resolve('shoppingBag');

const creditCard2 = container.resolve('creditCard');
const shoppingBag2 = container.resolve('shoppingBag');

creditCard.set('John Doe', 'Street 12', '0000-0000-0000', '123');
shoppingBag.addItem({name: 'Apple', price: 1.99});

creditCard2.set('Dan Scott', 'Street 90', '1111-1111-1111', '321');
shoppingBag2.addItem({name: 'Orange', price: 2.99});

container.resolve('transaction').transfer();
Enter fullscreen mode Exit fullscreen mode

Add Request/Scoped

We need to add an HTTP context to our container. Let's add a context parameter to the resolve method and a Map hashtable to save the scoped services.

class DIContainer {
  services = new Map();
  scopedServices = new Map(); // save the scoped services

  register(serviceName, serviceDefinition, scope, dependencies = []) {}

  // Add context
  resolve(serviceName, context = null) {}

}
Enter fullscreen mode Exit fullscreen mode

Cool, add an if statement for scoped lifetimes in the resolve method.

Check if there is a context otherwise throw an error.

check if the current context already exists in the scopedServices Map otherwise create it.

    if(service.scope === 'scoped') {
        if (!context) {
                throw new Error('Scoped services requires a context.');
            }

            if (!this.scopedServices.has(context)) {
                // key value pair of context and a new map with the services
                this.scopedServices.set(context, new Map());
            }
    }
Enter fullscreen mode Exit fullscreen mode

Now we have saved the context, let's find the saved context, add the service name and its instance, as well as the dependencies names and instances, and then resolve them.

    if(service.scope === 'scoped') {
        if (!context) {
                throw new Error('Scoped services requires a context.');
            }

            if (!this.scopedServices.has(context)) {
                this.scopedServices.set(context, new Map());
            }
            const contextServices = this.scopedServices.get(context);

            if (!contextServices.has(serviceName)) {
                const resolvedDependencies = service.dependencies.map(dependency=> this.resolve(dependency, context)); // Don't forget to include context
                const instance = new service.serviceDefinition(...resolvedDependencies);
                  contextServices.set(serviceName, instance);
            }

            return contextServices.get(serviceName);
    }
Enter fullscreen mode Exit fullscreen mode

The structure of the scoped services looks like this:

{ 
context1: {
            serviceName: instance,
            serviceName: instance
           },
context2: {
            serviceName: instance,
            serviceName: instance
           }
}
Enter fullscreen mode Exit fullscreen mode

We also need to pass the context to all dependencies being resolved. so let's do that also for transient and singleton services.

if (service.scope === 'transient') {
      // pass the context into dependencies
      const resolvedDependencies = service.dependencies.map(dependency => this.resolve(dependency, context)); 

      return new service.serviceDefinition(...resolvedDependencies);
    }

if (service.scope === 'singleton') {
        if (!service.instance) {
            // pass the context into dependencies
            const resolvedDependencies = service.dependencies.map(dependency => this.resolve(dependency, context));
            service.instance = new service.serviceDefinition(...resolvedDependencies);
        }
        return service.instance;
    }
Enter fullscreen mode Exit fullscreen mode

Remove context

Now let's add a function that cleans up the scoped services.

We add one extra method to our DI Container called clearContext.

clearContext(context) {
          this.scopedServices.delete(context);
          console.log("Deleted context");
      }
Enter fullscreen mode Exit fullscreen mode

Let's try it out.

We simulate a httpContext by creating an httpContextobject and sending it as an argument to the resolve function.

const container = new DIContainer();

container.register('creditCard', CreditCard, 'scoped');
container.register('shoppingBag', ShoppingBag, 'scoped');
container.register('transaction', Transaction, 'transient',['creditCard', 'shoppingBag'])
container.register('sendOrder', SendOrder, 'transient',['creditCard', 'shoppingBag'])

const httpContext = {
  headers: {
    "content-type": "application/json"
  },
  body: {
    url: "https://www.myStore.com/checkout"
  }
}
const httpContext2 = {
  headers: {
    "content-type": "application/json"
  },
  body: {
    url: "https://www.myStore.com/checkout"
  }
}

const creditCard = container.resolve('creditCard', httpContext);
const shoppingBag = container.resolve('shoppingBag', httpContext);

const creditCard2 = container.resolve('creditCard', httpContext2);
const shoppingBag2 = container.resolve('shoppingBag', httpContext2);

creditCard.set('John Doe', 'Street 12', '0000-0000-0000', '123');
shoppingBag.addItem({name: 'Apple', price: 1.99});

creditCard2.set('Dan Scott', 'Street 90', '1111-1111-1111', '321');
shoppingBag2.addItem({name: 'Orange', price: 2.99});

container.resolve('transaction', httpContext).transfer();
container.resolve('transaction', httpContext2).transfer();

container.clearContext(httpContext);
container.clearContext(httpContext2);
Enter fullscreen mode Exit fullscreen mode

In the console you should see:

Transfering 1.99 to John Doe
Transfering 2.99 to Dan Scott
Deleted context
Deleted context

Thanks for reading and happy coding!

Full example

class DIContainer {
  services = new Map();
  scopedServices = new Map();

  register(serviceName, serviceDefinition, scope, dependencies = []) {
    this.services.set(serviceName, {
      serviceDefinition,
      scope,
      dependencies,
      instance: null 
    });
  }
  resolve(serviceName, context = null) {
    const service = this.services.get(serviceName);

    if (!service) {
      throw new Error(`Service ${serviceName} not found`);
    }
    if (service.scope === 'transient') {
      const resolvedDependencies = service.dependencies.map(dependency => this.resolve(dependency, context));

      return new service.serviceDefinition(...resolvedDependencies);
    }

    if (service.scope === 'singleton') {
        if (!service.instance) {
            const resolvedDependencies = service.dependencies.map(dependency => this.resolve(dependency, context));
            service.instance = new service.serviceDefinition(...resolvedDependencies);
        }
        return service.instance;
    }
    if(service.scope === 'scoped') {
        if (!context) {
                throw new Error('Scoped services requires a context.');
            }

            if (!this.scopedServices.has(context)) {
                this.scopedServices.set(context, new Map());
            }
            const contextServices = this.scopedServices.get(context);

            if (!contextServices.has(serviceName)) {
                const resolvedDependencies = service.dependencies.map(dependency=> this.resolve(dependency, context)); 
                const instance = new service.serviceDefinition(...resolvedDependencies);
                  contextServices.set(serviceName, instance);
            }

            return contextServices.get(serviceName);
    }
  }
  clearContext(context) {
        this.scopedServices.delete(context);
        console.log('Deleted context');
    }
}

// Independent service
class CreditCard {
  owner;
  address;
  number;
  cvc;

  set(owner, address, number, cvc) {
    this.owner = owner;
    this.address = address;
    this.number = number;
    this.cvc = cvc;
  }
}
// Independent service
class ShoppingBag {
  items = [];
  total = 0;
  addItem(item) {
    this.items.push(item);
    this.total += item.price;
  }
}
// Dependent on credit card and shopping bag 
class Transaction {
  constructor(creditCard, shoppingBag) {
    this.creditCard = creditCard;
    this.shoppingBag = shoppingBag;
  }
  transfer() {
    console.log(`Transfering ${this.shoppingBag.total} to ${this.creditCard.owner}`);
  }
}

// dependent on credit card and shopping bag
class SendOrder {
  constructor(creditCard, shoppingBag) {
    this.creditCard = creditCard;
    this.shoppingBag = shoppingBag;
  }
  send() {
    console.log(`Sending ${this.shoppingBag.total} to ${this.creditCard.address}`);
  }
}

const container = new DIContainer();

container.register('creditCard', CreditCard, 'scoped');
container.register('shoppingBag', ShoppingBag, 'scoped');
container.register('transaction', Transaction, 'transient',['creditCard', 'shoppingBag'])
container.register('sendOrder', SendOrder, 'transient',['creditCard', 'shoppingBag'])

const httpContext = {
  headers: {
    "content-type": "application/json"
  },
  body: {
    url: "https://www.myStore.com/checkout"
  }
}
const httpContext2 = {
  headers: {
    "content-type": "application/json"
  },
  body: {
    url: "https://www.myStore.com/checkout"
  }
}

const creditCard = container.resolve('creditCard', httpContext);
const shoppingBag = container.resolve('shoppingBag', httpContext);

const creditCard2 = container.resolve('creditCard', httpContext2);
const shoppingBag2 = container.resolve('shoppingBag', httpContext2);

creditCard.set('John Doe', 'Street 12', '0000-0000-0000', '123');
shoppingBag.addItem({name: 'Apple', price: 1.99});

creditCard2.set('Dan Scott', 'Street 90', '1111-1111-1111', '321');
shoppingBag2.addItem({name: 'Orange', price: 2.99});

container.resolve('transaction', httpContext).transfer();
container.resolve('transaction', httpContext2).transfer();

container.clearContext(httpContext);
container.clearContext(httpContext2);
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
gwouite profile image
Guillaume

Hello, thanks for that. We can add one case : dependency registration with a scope AND context.

In which case ? Exemple : imagine you have 2 MySQL database connections, one for secured datas, one for logging. Then you can create 2 differently scoped dependency with the same DB Class and in different contexts. One you'll name it "DatabaseLog" that you scope in singleton, the other one "DatabaseSecure" that you scope differently

Bye, G.

Collapse
 
emanuelgustafzon profile image
Emanuel Gustafzon

Thanks for your input! That is a good point