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}`);
}
}
DI Container skeleton
class DIContainer {
services = new Map();
register() {}
resolve() {}
}
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();
}
}
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)
Resolve dependencies
An instance generator is not exciting so letโs add dependencies to the services.
These are the steps:
- Add an array of all the names of the dependencies.
- Resolve all the dependencies by looping over the array and calling the resolve function on all the services.
- 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);
}
}
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();
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
});
}
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);
}
}
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
});
}
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;
}
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();
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();
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) {}
}
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());
}
}
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);
}
The structure of the scoped services looks like this:
{
context1: {
serviceName: instance,
serviceName: instance
},
context2: {
serviceName: instance,
serviceName: instance
}
}
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;
}
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");
}
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);
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);
Top comments (2)
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.
Thanks for your input! That is a good point