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:
1export class Item {2id: string;3name: string;4description: string;5price: number;6createdAt: Date;78constructor(9id: string,10name: string,11description: string,12price: number,13createdAt: Date14) {15this.id = id;16this.name = name;17this.description = description;18this.price = price;19this.createdAt = createdAt;20}21}
Setup the contract for the repository:
1import { Collection, Filter, MongoClient } from "mongodb";23export class ItemsRepository implements IItemsRepository {4private static readonly collectionName = "items";56private readonly dbCollection: Collection<Item>;78private readonly filterBuilder: Filter<Item> = {};910constructor(client: MongoClient) {11const database = client.db(); // Assuming a database is selected in the client12this.dbCollection = database.collection<Item>(13ItemsRepository.collectionName14);15}1617public async getAllAsync(): Promise<ReadonlyArray<Item>> {18return await this.dbCollection.find(this.filterBuilder).toArray();19}2021// ...22}
Have some DTOs:
1export interface ItemDto {2id: string;3name: string;4description: string;5price: number;6createdAt: Date;7}
1export class Extensions {2public static asDto(item: Item): ItemDto {3return {4id: item.id,5name: item.name,6description: item.description,7price: item.price,8createdAt: item.createdAt,9};10}11}
When we consider building the controller avoid writing the repo dependency as follows:
1export class ItemsController {2private readonly itemsRepository = new ItemsRepository(); // AVOID!34constructor() {}56// GET /items7public async getAsync(8req: Request,9res: Response10): Promise<Response<ItemDto[]>> {11try {12const items = (await this.itemsRepository.getAllAsync()).map((item) =>13Extensions.asDto(item)14);15return res.json(items);16} catch (error) {17logger.error("Error occured when fetching items. ", error);18return res.status(500).json({ error: "Failed to fetch items" });19}20}2122// ...23}
Instead consider injecting the dependency as follows:
1export class ItemsController {2private readonly itemsRepository: IItemsRepository;34constructor(itemsRepository: IItemsRepository) {5this.itemsRepository = itemsRepository;6}78// GET /items9public async getAsync(10req: Request,11res: Response12): Promise<Response<ItemDto[]>> {13try {14const items = (await this.itemsRepository.getAllAsync()).map((item) =>15Extensions.asDto(item)16);17return res.json(items);18} catch (error) {19logger.error("Error occured when fetching items. ", error);20return res.status(500).json({ error: "Failed to fetch items" });21}22}2324// ...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.