Next 101: IoC Implementation with Inversify

ByLazuardy
10 min read
ioc
react
nextjs
Next 101: IoC Implementation with Inversify
Photo by Kelly Sikkema on Unsplash

Inversion of Control (IoC) is a design pattern in which the control of an object’s behaviour is inverted or moved outside the object. It helps you manage the flow of objects in a software application.


The control is inverted, and a container takes on the responsibility of managing the dependencies, allowing for greater modularity, flexibility, and testability.



If you're familiar with Dependency Injection, it is one of the technique to implement this IoC design.


Dependency Injection is a 25-dollar term for a 5-cent concept. It means giving an object its instance variables.

— James Shore

This pattern simply says, You don't make the object, I’ll provide it to you.



But Why?


Design pattern? Again? For fu*k sake, I’m tired enough with all of this 🤣. At first, I don’t think IoC or Dependency Injection really useful with Next framework. Normal people don’t use this also, haha.


But, there is something interesting that makes me curious. What if I use Repository Pattern with Next? I know that Next written with Javascript or Typescript, which is by default used with functional approach rather than OOP. And I don't care about that, actually.


I’m more a Backend guy, that usually talk using OOP-language such as Rust and PHP. I use Laravel framework as my daily driver.


Of course, I always implement Repository and Service Pattern in every projects that I’ve developed. The repository is where the data is stored. The service is what manipulates the data. Simple enough.


Using these patterns would really help you to develop a robust and highly-maintainable codebase if you need to manage data via APIs or directly to database in your project.


You can read more about it on article below if you find yourself interested with it.


Repository Design Pattern


The Repository and Service Pattern really works well with IoC. The truth is, it’s kinda needs IoC.


You may have a lot of repositories that used in a single service class. And it’s a dumb, stupid reason to create those repository instances one-by-one when you want to use it.


You require IoC, especially Dependency Injection, to automatically inject the instance when you require it.


Let Inversify Help You


To implement IoC in Next framework, we will use InversifyJS.


It’s a powerful and lightweight inversion of control container for JavaScript & Node.js apps powered by TypeScript. It has a good set of friendly APIs and can encourage the usage of the best OOP and IoC practices.


Among other IoC and Dependency Injection libraries, I prefer to use Inversify because of how simple it is and just for the sake of stability to be used on production. The good thing is, it’s as lightweight as 4 KB in size.


Inversify requires a modern Javascript engine with support for:



InversifyJS


Talk is Cheap, Show Me The Code


Typically, after you create a fresh Next project with Typescript and App-based Router, your project directory structure would look like this.


project
|
+-- app -> your application directory
|
+-- components -> your ui component directory
|
+-- public -> your public asset directory
|
+-- package.json
|
+-- tsconfig.json
|
+-- next-env.d.ts
|
+-- next.config.mjs

Install the required dependencies using these command.


npm install inversify reflect-metadata --save

Inversify required for implementing IoC in Typescript, and reflect-metadata required for Decorator and Decorator Metadata support for Typescript.


The type definitions are included in the Inversify npm package. It requires TypeScript 2.0 and some compilation options such as experimentalDecorators, emitDecoratorMetadata, types, and lib in your tsconfig.json file.



{
"compilerOptions": {
"target": "es5",
"lib": ["es6", "dom"],
"types": ["reflect-metadata"],
"module": "commonjs",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

To get started, create these files that required for Inversify’s container and reflect-metadata.


// @/lib/di/container.ts

import "reflect-metadata";
import { Container } from "inversify";

const container = new Container();

export default container;

// @/lib/di/reflect-metadata.ts

"use client";

import "reflect-metadata";

Next uses SSR by default for each page or component that you have, but the reflect-metadata library needs to be imported on the client-side or weird errors would happen. That’s why you need to import reflect-metadata.ts (as a client-component) to your root layout file.


// @/app/layout.tsx

import "@/lib/di/reflect-metadata"; // import the reflect-metadata

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
...
}

Now, your project directory structure would look like this.


project
|
+-- app -> your application directory
|
+-- components -> your ui component directory
|
+-- public -> your public asset directory
|
+-- lib -> your library directory
| |
| +-- di -> your dependency injection directory
| |
| +-- container.ts -> the container
| |
| +-- reflect-metadata.ts -> the reflect-metadata client-component
|
+-- package.json
|
+-- tsconfig.json
|
+-- next-env.d.ts
|
+-- next.config.mjs

And now Inversify should work properly and ready to be used.


The Model


This so-called model is just a strict-typing for data that used in your code. I’m using a “model” name because I’m more familiar with it. You may call this as type and create the directory named types in your project root if you want to.


For example, I’ll create a Product model for data that will be used as product in my application.


// @/models/product.ts

type Product = {
id: string;
image_url: string;
image_blur_data_url: string | undefined;
name: string;
price: number;
unit: string;
short_description: string;
description: string;
statement_description: string;
marketing_feature: string;
};

export default Product;

The Repository


A repository class used as a data-store so that your other code can access the required data via this class. The repository is a single source of truth for your app.


As an example, let’s create a class named ProductRepository. The example below uses a strict type of Product model that was previously created.


// @/repositories/product-repository.ts

import "reflect-metadata";
import { injectable } from "inversify";
import type Product from "@/models/product";

const products = [ ... ]; // a list of dummy product data

@injectable()
export default class ProductRepository {
async getProducts(): Promise<Array<Product>> {
return products.map((product: unknown) => product as Product);
}

async getProduct(id: string): Promise<Product | undefined> {
const product = products.find((product: unknown) => product.id === id);

if (product) {
return product as Product;
}

return undefined;
}
}

The above code is just simulating to get the data from a list of dummy data.


@injectable decorator need to be used so that Inversify know that the class can be injected to another instance.


async is typically required if your code needs to be executed asynchronously because of taking some time to finish. You may want to use this if you need to interact with database or external APIs.


The Service


A service class used to manipulate the data via repository class that available. You may use multiple repository in singe service class if you need to.


This is where Dependency Injection with Inversify helps you. It can automatically inject the required repositories in your service class via @inject decorator.


As an example, let’s create a class named ProductService. The example below uses the ProductRepository and strict type of Product model that was previously created.


// @/services/product-service.ts

import "reflect-metadata";
import { inject, injectable } from "inversify";
import ProductRepository from "@/repositories/product-repository";
import type Product from "@/models/product";

@injectable()
export default class ProductService {
@inject(ProductRepository)
private productRepository: ProductRepository;

async getProducts(query: string): Promise<Array<Product>> {
let products = await this.productRepository.getProducts();

if (query !== "") {
return products.filter((product: Product) =>
product.name.toLowerCase().includes(query.toLowerCase()),
);
}

return products;
}

async getProduct(id: string): Promise<Product | undefined> {
const product = await this.productRepository.getProduct(id);

if (product) {
return product as Product;
}

return undefined;
}
}

According to your needs, you can add more method to help you manage your own data with this service class.


@injectable decorator need to be used so that Inversify know that the class can be injected to another instance.


@inject decorator need to be used to tell the Inversify to inject the required instance of the class so that we can use this instance to access the repository’s methods.


The Container


This is the final step to take for a properly working IoC. After you’ve created the model (type), repository, and service, you need to register those class instances on the IoC container.


People also often call the class instance as “dependency”. It’s just the same thing, different word. That’s why it’s named Dependency Injection.


If the dependency been registered to the container, it can be injected into any instance that require it at the runtime of your app.


The container will always be available at the runtime of your application.

// @/lib/di/container.ts

import "reflect-metadata";
import { Container } from "inversify";
import ProductRepository from "@/repositories/product-repository";
import ProductService from "@/services/product-service";

const container = new Container();

container.bind(ProductRepository).to(ProductRepository).inSingletonScope();

container.bind(ProductService).to(ProductService).inSingletonScope();

export default container;

You also can specify the scope for the class instance.



The Hook


This step is optional, intended to help you access the container in other component of your app.


// @/lib/di/hook.ts

import container from "@/lib/di/container";
import ProductService from "@/services/product-service";

export function getProductService(): ProductService {
return container.get(ProductService);
}

Typically, you only need the method for accessing the service, because it's the one who manages your data via repository. You can add a method to access your repository also if you need to.


Try to not write any redundant, dead-code. Keep it simple, stupid.


You can also use the React hook naming convention, such as useProductService if necessary. But by doing this makes the method can only be called on a page or component. You may want this method to be callable on other file, such as in Server Action.


Reusing Logic with Custom Hooks - React


Finally, now your project directory structure would look like this.


project
|
+-- app -> your application directory
|
+-- components -> your ui component directory
|
+-- public -> your public asset directory
|
+-- lib -> your library directory
| |
| +-- di -> your dependency injection directory
| |
| +-- container.ts -> the container
| |
| +-- hook.ts -> the container hook
| |
| +-- reflect-metadata.ts -> the reflect-metadata client-component
|
+-- models -> your model directory
| |
| +-- product.ts -> the product model
|
+-- repositories -> your repository directory
| |
| +-- product-repository.ts -> the product repository
|
+-- services -> your service directory
| |
| +-- product-service.ts -> the product service
|
+-- package.json
|
+-- tsconfig.json
|
+-- next-env.d.ts
|
+-- next.config.mjs

Using It In The App


Using the container to get an instance is pretty easy.


For example, I have a page that needed to show the list of the product data.


// @/app/page.tsx

import { getProductService } from "@/lib/di/hook";
import { Suspense } from "react";
import ProductList from "@/components/product-list";
import ProductListSkeleton from "@/components/product-list-skeleton";

type Props = {
searchParams?: {
search?: string;
page?: string;
};
};

export default async function Page({ searchParams }: Props) {
// get the query params
const query = searchParams?.search || "";
const currentPage = Number(searchParams?.page) || 1;

// get the product items
const items = await getProductService().getProducts(query);

return (
<Suspense key={query + currentPage} fallback={<ProductListSkeleton />}>
<ProductList items={items} />
</Suspense>
);
}

Pretty easy right? The code now looks more clean because the data access is now centralized and managed by IoC 🎉.


It may look simple for now, but trust me, you’ll get the point once your project have tons of repositories and services.


Conclusion


Photo by Jeff Sheldon on Unsplash

Using Inversion of Control (IoC) with Dependency Injection can really make your codebase more clean, robust, and highly-maintainable.


It can also eliminate bad-smelly code that duplicates among your codebase. Ewh! 🤮


If you more like to use Server Action in your Next app, you can also use IoC with it. It's simple, learn once, use everywhere.


You may want to read these article to enhance your skill and knowledge in implementing Repository Pattern with Next.


Building a Robust Repository Layer with Next.js 14 and MongoDB: A Comprehensive Guide


🚀 Sample Project


You can run the demo project that I develop using Inversify as the IoC. The project is licensed under MIT Licence.


GitHub - ezralazuardy/ebuy: 🛒 Dummy App for E-Commerce Workflow.


In Plain English 🚀


Thank you for being a part of the In Plain English community! Before you go:





Next 101: IoC Implementation with Inversify was originally published in JavaScript in Plain English on Medium, where people are continuing the conversation by highlighting and responding to this story.


We like to write these kind of article.

Follow us onMediumto get the latest updates.

We also active on these social media platforms,

InstagramLinkedInMediumGitHub
Lazuardy
Helping your businesses

grow bigger.

PT Inovasi Kolektif Digital
Company No. 2910240067449
VAT No. 12.655.463.3-503.000
Registered in The Republic of Indonesia
© Lazuardy 2024. All rights reserved.