Blogs
Dependency Injection

Dependency Inversion Principle

Before diving to Dependency Injection, first understand Dependency Inversion.

Dependency Inversion Principle (DIP) states that code should depend on abstractions as opposed to concrete implementations.

Benefits of Dependency Inversion Principle

The Dependency Inversion Principle offers several benefits, such as:

  • Decoupling: By adhering to DIP, the code becomes more modular, making it easier to maintain and extend.
  • Testability: Decoupled code is easier to test, as each module can be tested independently.
  • Reusability: Modules become more reusable since they are less dependent on other modules.

To implement the Dependency Inversion Principle, you should focus on creating abstractions (interfaces or abstract classes) and making both high-level and low-level modules depend on them. By doing so, you can avoid direct dependencies between high-level and low-level modules.

Service Lifetimes

Now that we’ve covered DIP, let’s talk about Dependency Injection.

There are service lifetimes when it comes to DI:

  • Transient
  • Singleton
  • Scoped

Transient services are created each time they’re requested from the service container.

  • Registers a new instance of the service every time it is requested by any part of the application.
  • Each time a service is requested, a new instance is created, and it is not reused for subsequent requests.
  • This is suitable for lightweight stateless services that don’t require any shared state among different parts of the application.

Scoped services are created once within the scope’s lifetime. For ASP.NET Core applications, a new scope is created for each request. This is how you can resolve scoped services within a given request.

  • Registers a single instance of the service per scope. A scope could be an HTTP request or any other defined scope, depending on the application setup (e.g., web applications with web requests, or using an explicit scope in other scenarios).
  • For the duration of a scope, the same instance is reused for all requests within that scope. When a new scope is created, a new instance will be provided.
  • This is appropriate for services that maintain state within a specific scope, such as per-request services in web applications.

Singleton services are created either:

  • Same instance is reused for all requests, regardless of the scope or the number of times the service is requested.
  • Use this for services that are stateless or immutable and can be safely shared across the application.

Example

Let’s consider the following Items API:

This is the item entity:

entity.ts
1
export class Item {
2
id: string;
3
name: string;
4
description: string;
5
price: number;
6
createdAt: Date;
7
8
constructor(
9
id: string,
10
name: string,
11
description: string,
12
price: number,
13
createdAt: Date
14
) {
15
this.id = id;
16
this.name = name;
17
this.description = description;
18
this.price = price;
19
this.createdAt = createdAt;
20
}
21
}

Setup the contract for the repository:

items.repository.ts
1
import { Collection, Filter, MongoClient } from "mongodb";
2
3
export class ItemsRepository implements IItemsRepository {
4
private static readonly collectionName = "items";
5
6
private readonly dbCollection: Collection<Item>;
7
8
private readonly filterBuilder: Filter<Item> = {};
9
10
constructor(client: MongoClient) {
11
const database = client.db(); // Assuming a database is selected in the client
12
this.dbCollection = database.collection<Item>(
13
ItemsRepository.collectionName
14
);
15
}
16
17
public async getAllAsync(): Promise<ReadonlyArray<Item>> {
18
return await this.dbCollection.find(this.filterBuilder).toArray();
19
}
20
21
// ...
22
}

Have some DTOs:

dtos.ts
1
export interface ItemDto {
2
id: string;
3
name: string;
4
description: string;
5
price: number;
6
createdAt: Date;
7
}
extensions.ts
1
export class Extensions {
2
public static asDto(item: Item): ItemDto {
3
return {
4
id: item.id,
5
name: item.name,
6
description: item.description,
7
price: item.price,
8
createdAt: item.createdAt,
9
};
10
}
11
}

When we consider building the controller avoid writing the repo dependency as follows:

items.controller.ts
1
export class ItemsController {
2
private readonly itemsRepository = new ItemsRepository(); // AVOID!
3
4
constructor() {}
5
6
// GET /items
7
public async getAsync(
8
req: Request,
9
res: Response
10
): Promise<Response<ItemDto[]>> {
11
try {
12
const items = (await this.itemsRepository.getAllAsync()).map((item) =>
13
Extensions.asDto(item)
14
);
15
return res.json(items);
16
} catch (error) {
17
logger.error("Error occured when fetching items. ", error);
18
return res.status(500).json({ error: "Failed to fetch items" });
19
}
20
}
21
22
// ...
23
}

Instead consider injecting the dependency as follows:

item.controller.ts
1
export class ItemsController {
2
private readonly itemsRepository: IItemsRepository;
3
4
constructor(itemsRepository: IItemsRepository) {
5
this.itemsRepository = itemsRepository;
6
}
7
8
// GET /items
9
public async getAsync(
10
req: Request,
11
res: Response
12
): Promise<Response<ItemDto[]>> {
13
try {
14
const items = (await this.itemsRepository.getAllAsync()).map((item) =>
15
Extensions.asDto(item)
16
);
17
return res.json(items);
18
} catch (error) {
19
logger.error("Error occured when fetching items. ", error);
20
return res.status(500).json({ error: "Failed to fetch items" });
21
}
22
}
23
24
// ...
25
}

Now, we have the following:

We can do a lot more when injecting depencies. There’s libraries such as tsyringe, InversifyJS, and more. These libraries will allow you to contruct and inject your depencies as needed. If you come from a .NET background, this is very familiar process.

What if you want to generalize this functionality across many services? Hence, if you have Microservices that need access to the same depency. Move on to the Generalizing Dependency Injection section.