3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TSKaigiAdvent Calendar 2024

Day 12

依存性注入から見るバックエンド TypeScript の強み

Last updated at Posted at 2024-12-11

この記事はTSKaigi Advent Calendar 2024 12日目の記事です。

はじめに

こんにちは、TSKaigi 2025 運営の tsuyuni(梅雨)です。

この記事の前半では、

  • TypeScript が提供する強力な型システムおよびデコレータ
  • NestJS が提供する DIコンテナ

の関係性に注目し、これらを組み合わせた設計パターンや技術について解説します。

また、後半では依存性逆転の原則を理解することでソフトウェアの保守性を高めていくような工夫について考えていき、バックエンドの開発に TypeScript を用いることの利点を探っていこうと思います。

DIとは?

依存性注入(Dependency Injection, DI)とは、オブジェクトが必要とする依存オブジェクトを外部から提供するデザインパターンおよびメカニズムのことを指します。これにより、コードの再利用性やテストの容易性が向上し、アプリケーション全体の柔軟性を高めることができます。

これだけの説明ではピンとこない方も多いと思うので、実際のコードを例に挙げて見てみましょう。

DI を用いるパターン

以下のコードでは、SampleController のインスタンスを作る際に SampleService のインスタンスが必要となります。このような意味で「SampleControllerSampleService に依存している」と表現します。

class SampleService {
  hello() {
    console.log("Hello, World!");
  }
}

class SampleController {
  constructor(private sampleService: SampleService) {}
  get() {
    return this.sampleService.hello();
  }
}

const sampleService = new SampleService();
// SampleServiceのインスタンスが必要
const sampleController = new SampleController(sampleService);
sampleController.get();

これが DI の基本的な形です。

DI は言語に依らない設計パターンであるため、オブジェクト指向の言語であれば基本的にどの言語でも実装することができます。

DI を用いないパターン

比較として、DI を用いない場合はどのようなコードになるかも確認しておきましょう。

class SampleService {
  hello() {
    console.log("Hello, World!");
  }
}

class SampleController {
  private sampleService: SampleService;

  constructor() {
    this.sampleService = new SampleService();
  }

  get() {
    return this.sampleService.hello();
  }
}

const sampleController = new SampleController();
sampleController.get();

このコードでは、SampleController の内部で SampleService のインスタンスを作成しています。そのため、内部的には SampleService に依存することのない設計となっています。

DI パターンを採用するメリット

では、DIを用いないこの設計では何が問題なのでしょうか?以下のように SampleService をモックするパターンを考えてみましょう。

interface ISampleService {
  hello(): void;
}

class SampleService implements ISampleService {
  hello() {
    console.log("Hello, World!");
  }
}

class MockSampleService implements ISampleService {
  hello() {
    console.log("[Mock] Hello, World!");
  }
}

class SampleController {
  constructor(private sampleService: ISampleService) {}
  get() {
    return this.sampleService.hello();
  }
}

const mockSampleService = new MockSampleService();
// MockSampleServiceのインスタンスを注入
const sampleController = new SampleController(mockSampleService);
sampleController.get();

DI を用いた設計では、注入するインスタンスを sampleService から mockSampleService に変更するだけでサービスをモックすることができます。

コントローラ内部の実装を変更する必要がないため、サービスとコントローラが疎結合であると表現できます。

一方で、DI を用いない設計ではサービスをモックする際、以下のようにコントローラ内部の実装を変更する必要があります。

interface ISampleService {
  hello(): void;
}

class SampleService implements ISampleService {
  hello() {
    console.log("Hello, World!");
  }
}

class MockSampleService implements ISampleService {
  hello() {
    console.log("[Mock] Hello, World!");
  }
}

class SampleController {
  private sampleService: ISampleService;

  constructor() {
    // MockSampleServiceのインスタンスを生成
    this.sampleService = new MockSampleService();
  }

  get() {
    return this.sampleService.hello();
  }
}

const sampleController = new SampleController();
sampleController.get();

コントローラ内部の実装を直接変更する必要があるため、サービスとコントローラが密結合であると表現できます。これはテストにおけるモックとしては適切ではないですし、モジュールの柔軟性が高いとも言えません。

このように、DI を用いることでサービス(=ビジネスロジック)とコントローラ(=ルーティング)を明確に分離したコードの記述が可能になります。

注意として、DI を用いた設計パターンが必ずしも正しいというわけではありません。特に、簡単なプロジェクトや小規模なアプリケーションにおいては DI を使うことでコードが不必要に複雑になり、保守性が悪化する可能性があります。また、過度な抽象化はコードの可読性を損ねる恐れがあります。

DI 設計パターンを TypeScript で採用するメリット

先ほど紹介したように、DI は言語に依らない設計パターンとなっています。しかし、TypeScript で DI を採用することには一定のメリットがあります。

その一つとして、デコレータの存在が挙げられます。

@Injectable デコレータ

TypeScript はデコレータを用いたクラスやクラスメンバの修飾をサポートしており、DI コンテナを提供する NestJS と組み合わせることで、簡潔な依存性の記述が可能となります。

@Injectable デコレータをクラスに付与することで、そのクラスが DI コンテナによって管理されるプロバイダーとして認識されます。

最初に例示したコードは以下のように責務の分離を行うことができます。

sample.service.ts
// ビジネスロジックの記述
import { Injectable } from "@nestjs/common";

@Injectable
export class SampleService {
  hello() {
    console.log("Hello, World!");
  }
}
sample.controller.ts
// ルーティングの記述
export class SampleController {
  constructor(private sampleService: SampleService) {}
  get() {
    return this.sampleService.hello();
  }
}
sample.module.ts
// 依存性の記述
import { Module } from "@nestjs/common";
import { SampleService } from "./sample.service";
import { SampleController } from "./sample.controller";

@Module({
  controllers: [SampleController],
  providers: [SampleService],
})
export class SampleModule {}

デコレータを用いることで直接インスタンスを生成することなく依存性の注入を行うことができるようになり、より簡潔なコードとなりました。

@Injectable デコレータは @nestjs/common によって提供されているデコレータですが、NestJS の DI コンテナを用いない場合でも InversifyJS のような軽量ライブラリを用いて同等のことを行えます。

TypeScript で DI を採用するメリットとしてはもう一つ、TypeScript の提供する強力な型システムが挙げられます。

TypeScript の型システム

例えば、以下のように Node.js の環境変数に応じて使用するサービスを切り替えるコードを useFactory() を用いた動的プロバイダーとして実装することを考えてみましょう。

interface ISampleService {
  fetchData(): Promise<string>;
}

// 本番環境で使用されるサービスは外部のAPIを使用してデータを返却する
class ProductionSampleService implements ISampleService {
  async fetchData() {
    const res = await fetch("https://api.example.com");
    const { content }: { content: string } = await res.json();
    return content;
  }
}

// 開発環境で使用されるサービスはモックデータを使用してデータを返却する
class DevelopmentSampleService implements ISampleService {
  async fetchData() {
    const content = "Hello, World!";
    return content;
  }
}

class SampleController {
  constructor(private sampleService: ISampleService) {}

  async get() {
    return await this.sampleService.fetchData();
  }
}

@Module({
  providers: [
    {
      provide: "ISampleService",
      useFactory: (): ISampleService => {
        // Node.jsの環境変数に応じて使用するサービスを切り替える
        if (process.env.NODE_ENV === "production") {
          return new ProductionSampleService();
        } else {
          return new DevelopmentSampleService();
        }
      },
    },
  ],
  controllers: [SampleController],
})
class SampleModule {}

ProductionSampleServiceDevelopmentSampleService はそれぞれ ISampleService インターフェスを実装するクラスであるため、メンバ関数は型安全に実装されています。

また、useFactory() の返り値に ISampleService の型を指定することで、型チェックにより誤ったサービスを注入してしまうことを防げます。今回の場合は、ISampleService を実装しているクラスのインスタンスのみが注入され、それ以外のクラスでは型エラーが発生します。

このように、プロバイダとコンシューマ間で型の安全性を保つことができるのもTypeScript の強みの一つだと思います。

加えてこの設計では DevelopmentService(開発環境)と ProductionService(本番環境)でのビジネスロジックを分離することができるため、保守性の高いコードの記述ができます。

依存性逆転の原則

それでは、今度は依存性逆転の原則について見ていきましょう。

依存性逆転の原則(Dependency Inversion Principle, DIP)とは、ソフトウェアモジュールの疎結合を実現するための考え方であり、SOLID原則の一つとして知られています。

DIP は

  • 上位のモジュールは、下位のモジュールに依存してはならない。両者とも抽象に依存すべきである。
  • 抽象は実装の詳細に依存してはならない。実装の詳細が抽象に依存すべきである。

という2つの規則から成り立っています。

これ以降、「モジュール」という表現は「クラス」と置き換えてもらっても結構です。NestJS の Module を指している訳ではないということに注意してください。

原則を満たさない例

// 下位のモジュール
class SampleService {
  hello() {
    console.log("Hello, World!");
  }
}

// 上位のモジュール
class SampleController {
  constructor(private sampleService: SampleService) {}
  get() {
    return this.sampleService.hello();
  }
}

const sampleService = new SampleService();
const sampleController = new SampleController(sampleService);
sampleController.get();

上に示したコードでは、単純に SampleControllerSampleService に依存するような構造となっています。すなわち、SampleController が上位のモジュール、SampleService が下位のモジュールであり、このままでは DIP の

  • 上位のモジュールは、下位のモジュールに依存してはならない。両者とも抽象に依存すべきである。

という規則に反した構造となっています。

原則を満たす例

そこで、上位・下位それぞれのモジュールが抽象(=インターフェス)に依存するような構造に書き変えたものが以下のコードになります。

// 抽象
interface ISampleService {
  hello(): void;
}

// 下位のモジュールは抽象に依存する(抽象を実装する)
class SampleService implements ISampleService {
  hello() {
    console.log("Hello, World!");
  }
}

// 上位のモジュールは抽象に依存する
class SampleController {
  constructor(private sampleService: ISampleService) {}
  get() {
    return this.sampleService.hello();
  }
}

const sampleService = new SampleService();
const sampleController = new SampleController(sampleService);
sampleController.get();

このコードでは上位のモジュールである SampleController が抽象である ISampleService に依存し、下位のモジュールであるSampleService も 抽象であるISampleService に依存するような構造になっています。

元々「上位のモジュール → 下位のモジュール」となっていた依存の矢印が、「抽象 ← 下位のモジュール」という逆方向の依存の矢印に逆転していることから、依存性逆転と呼ばれています。

モジュール間の関係がこの依存性逆転の原則を満たしていると、どのような嬉しいことがあるのでしょうか?

実は、これは結局初めに言及したコードの再利用性テストの容易性の向上に帰着します。

コードの再利用性

サービスをモックする例で触れたように、新しい下位のモジュールを追加する際、同じように抽象を満たすように実装するだけで済むため、上位のモジュールへの影響を最小限に抑えることができます。

// 抽象
interface ISampleService {
  hello(): void;
}

// 下位のモジュール
class SampleService implements ISampleService {
  hello() {
    console.log("Hello, World!");
  }
}

// 新しい下位のモジュールは抽象を満たすように実装される
class MockSampleService implements ISampleService {
  hello() {
    console.log("[Mock] Hello, World!");
  }
}

// 上位のモジュールは影響を受けない
class SampleController {
  constructor(private sampleService: ISampleService) {}
  get() {
    return this.sampleService.hello();
  }
}

下位モジュールは抽象を満たしてさえいれば上位モジュールで利用することができるため、モジュール間の疎結合を実現できています。

テストの容易性

また、上記のコードからも分かる通りコントローラに対して SampleServiceMockSampleService のどちらも注入することができるため、テスト時のモックが容易になります。

DI と依存性逆転の原則の考え方を用いたことで、アプリケーションで使用するロジックとモック時に使用するロジックは完全に分離されており、相互に影響を与えることのないような実装になっています。

このように、依存性逆転の原則はソフトウェアの柔軟性・保守性を保つ上で非常に有用な考え方であるといえます。

おわりに

以上が、TypeScriptが提供する強力な型システムおよびデコレータと NestJS が提供する DI コンテナの関係性および依存性逆転の原則の解説となりました。

今回の記事で重要な点をまとめておきましょう。

  • 依存性注入(DI)とは、オブジェクトが必要とする依存オブジェクトを外部から提供するデザインパターンのことを指す
  • 依存性逆転の原則(DIP)を満たすことでモジュールの疎結合を実現でき、コードの再利用性とテストの容易性が向上する
  • TypeScript の型システムおよびデコレータと NestJS の DI コンテナを活用することでこれらの設計思想の利点を最大限享受し、アプリケーションの柔軟性・保守性を高めることができる

バックエンドに TypeScript を採用するかどうかは定期的に話題になるテーマですが、このような視点で TypeScript を選んでみるのも面白いのではと思います。

TypeScript の今後には期待が募るばかりですね。

最後までお読みいただきありがとうございました。

TSKaigi について

2025 年 5 月 23 日 / 24 日 に TSKaigi 2025 が開催されます。
TSKaigi は日本最大級の TypeScript をテーマとした技術カンファレンスです。(前回の参加者は 2000 人以上)
TypeScript に興味のある方は、ぜひ公式サイトや X を確認してみてください。

▼ TSKaigi 2025 ティザーサイト / 公式 X

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?