Next.jsでDIコンテナを導入する方法(inversify編)

こんにちは

PS/SLの佐々木です

今回はNext.jsにDIコンテナを導入したいと思います。

TypescriptのDIコンテナライブラリはinversifyJSTsyringeの二つが有名なようですが、今回はinversifyを採用しました

今回は導入方法の動作の検証がメインのためDI、DIコンテナについては軽くしか触れません。

DIコンテナとは

DI(Dependency Injection)コンテナとは、「依存関係(=使いたい機能やクラス)」を自動で注入(Inject)してくれる仕組みです。

DIコンテナを導入するメリット

  • 依存関係の管理が楽になる
    • どのクラスがどの機能を使っているか、一元管理できる。
  • テストがしやすくなる
    • モックやスタブの差し替えが簡単になる。
  • コードの再利用性・拡張性が上がる
    • 実装の入れ替えが柔軟にできる(例えば本番用と開発用で処理を変える、など)。

導入方法

Next.js プロジェクト作成

npx create-next-app

Inversify JS インストール

npm install inversify@alpha reflect-metadata

今回作成するディレクトリ構成

今回使用するのはNext.js v15になります。
/app/apiを基準のディレクトリとして作業を行っています

.
├── (controller)
│   └── route.ts // ルーティング
├── instrumentation.ts // 
├── interface
│   ├── repository.ts
│   └── service.ts
├── inversify.config.ts // 識別子と実装クラスの関係付け
├── repository
│   └── index.ts // DBにアクセスするリポジトリ層
├── service
│   └── index.ts // ビジネスロジックを実装するservice層
└── type
    └── symbol
        └── type.ts // 識別の定義

それぞれのディレクトリとファイルの責務は上記の通りになります。

またDI を行う際に@Injectable()@Inject() などのデコレーターを使用するためtsconfigに以下の設定を追加します。

{
  "compilerOptions": {
    "target": "ES2017",
		...
    "experimentalDecorators": true, // 追加
    "emitDecoratorMetadata": true, // 追加
    ...
}

実装

では早速動かしてみましょう

今回はDIコンテナなしの場合とありの場合でどれぐらい違うのかをみていただくために、DIコンテナを使用しないバージョンも載せておきます。

今回実装するのは簡単なUserの取得と作成の処理になります。

またDBはないのでRepository層はほぼ何も処理はしてません

動作確認(DIコンテナなしの場合)

まずはDIコンテナなしの場合以下のようになります。(特に見なくても大丈夫の方はDIコンテナありの場合まで読み飛ばしてください)

interface/repository.ts

import { User } from "../type";

export interface IUserRepository {
    getById(id: string): User,
    create(user: User): void,
}

interface/service.ts

import { User } from "../type";

export interface IUserService {
    get(id: string): User
    create(user: User): void
}

type.ts

export type User = {
    id: string,
    name: string,
    email: string,
    address: string
}

route.ts

import { NextResponse } from "next/server"
import { UserService } from "../service"
import { User } from "../type"

export const GET = () => {
    const service = new UserService()
    const user = service.get('user0001')
    return NextResponse.json(user)
}

export const POST = () => {
    const service = new UserService()
    const body: User = {
        id: 'user0002',
        name: 'sasaki2',
        address: '東京都港区...',
        email: 'sasaki2@sios.jp'
    }
    const user = service.create(body)
    return NextResponse.json('ok')
}

service/index.ts

import { IUserService } from "../interface/service";
import { UserRepository } from "../repository";
import { User } from "../type";

export class UserService implements IUserService{
    private repository: UserRepository
    constructor(){
        this.repository = new UserRepository()
    }
    
    get(userId: string){
        return this.repository.getById(userId)
    }

    create(user: User) {
        this.repository.create(user)
    }
}

repository/index.ts

import { IUserRepository } from "../interface/repository";
import { User } from "../type";

export class UserRepository implements IUserRepository {
    constructor(){}

    getById(id: string): User {
        return {
            id: id,
            name: 'kanta sasaki',
            email: 'sasaki@sios.jp',
            address: '東京都港区....'

        }
    }

    create(user: User): void {
        // prisma.create({}) ...
    }
}

DIコンテナを使用しない場合にはcontroller, seviceそれぞれで依存しているクラスのインスタンスを作成しているのがわかると思います。

これだと具体的な実装に依存してしまっており、テストの際にダミーのRepositoryと入れ替えが難しくなってしまったり、依存している実装の変更の影響を受けやすくなってしまいます。

では次にDIコンテナありの場合を見てみます。

動作確認(DIコンテナありの場合)

識別子の定義

type/symbol/type.ts

const TYPES = {
  // repository
  IUserRepository: Symbol.for('IUserRepository'),

  // service
  IUserService: Symbol.for('IUserService')
};

export default TYPES;

識別子と実装クラスの関係付け

inversify.config.ts

import { Container } from 'inversify';
import TYPES from './type/symbol/type';
import { IUserRepository } from './interface/repository';
import { UserRepository } from './repository';
import { IUserService } from './interface/service';
import { UserService } from './service';

const diContainer = new Container();
diContainer.bind<IUserRepository>(TYPES.IUserRepository).to(UserRepository);
diContainer.bind<IUserService>(TYPES.IUserService).to(UserService);
export { diContainer };

Symbolで定義した識別子と具体的な実装クラスは上記のように紐づけます。

DIコンテナを使用してRepositoryとServiceの依存関係を解決する

controller/route.ts

import { NextResponse } from "next/server"
import { User } from "../type"
import { diContainer } from "../inversify.config"
import { IUserService } from "../interface/service"
import TYPES from "../type/symbol/type"

export const GET = () => {
    const service = diContainer.get<IUserService>(TYPES.IUserService)
    const user = service.get('user0001')
    return NextResponse.json(user)
}

export const POST = () => {
    const service = diContainer.get<IUserService>(TYPES.IUserService)
    const body: User = {
        id: 'user0002',
        name: 'sasaki2',
        address: '東京都港区...',
        email: 'sasaki2@sios.jp'
    }
    const user = service.create(body)
    return NextResponse.json('ok')
}

service/index.ts

import { inject, injectable } from "inversify";
import { IUserService } from "../interface/service";
import { UserRepository } from "../repository";
import { User } from "../type";
import TYPES from "../type/symbol/type";
import type { IUserRepository } from "../interface/repository";

@injectable()
export class UserService implements IUserService{
    constructor(
        @inject(TYPES.IUserRepository) private readonly repository: IUserRepository
    ){}
    
    get(userId: string){
        return this.repository.getById(userId)
    }

    create(user: User) {
        this.repository.create(user)
    }
}

repository/index.ts

import { injectable } from "inversify";
import { IUserRepository } from "../interface/repository";
import { User } from "../type";

@injectable()
export class UserRepository implements IUserRepository {
    constructor(){}

    getById(id: string): User {
        return {
            id: id,
            name: 'kanta sasaki',
            email: 'sasaki@sios.jp',
            address: '東京都港区....'

        }
    }

    create(user: User): void {
        // prisma.create({}) ...
    }
}

Point

  1. 依存される側のclassには@injectable() のデコレーターを付与する
  2. コンストラクタでDIコンテナを用いて依存解決する場合には@inject(識別子) private readonly repository: IUserRepository のように依存解決を行うことができ@inject デコレーターの引数に識別子を入れることによってinversify.config.ts で定義した関連付けに応じで解決することができる
  3. コンストラクタを使用しない場合には const service = diContainer.get<IUserService>(識別子) のような形で依存解決を行うことができる

DIコンテナの初期化

最後にサーバー起動時にDIコンテナを初期化しておきましょう

Next.js15よりnext.configinstrumentationHook:true を追加しなくてもinstrumentation.ts という名前のファイルを作成すればサーバー起動時に一回だけ読み込んでくれるようなのでこちらを使用します

import 'reflect-metadata';

export async function register() {
  if (process.env['NEXT_RUNTIME'] === 'nodejs') {
    await import('./inversify.config');
  }
}

こんな感じでAPIからユーザー情報が取得できていると思います。

またDIコンテナで依存関係を解決した後の型を見てみるとIUserRepository となっているため抽象に依存していることがわかります。

これによってテストの際に使用する具象クラスの入れ替えも非常に楽になります。

使ってみた感想

inverifyJSを使用してみて非常にシンプルで軽量なライブラリのためキャッチアップも非常に楽で直感的に使えるライブラリだと感じました。

もちろんDIを使わないものと比べたら冗長な感じになりますが、それ以上に受けられる恩恵は大きいと感じています。

付録

各種バージョンの確認にお使いください

{
  "name": "dicontainer",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "inversify": "^7.0.0-alpha.5",
    "next": "15.2.4",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "reflect-metadata": "^0.2.2"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

1人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です