TypeScript でライブラリを使わない DI 方法の比較
株式会社GENDA FE/BE開発部のshinnokiです。
この記事は GENDA Advent Calendar 2025 シリーズ2 Day 12 の記事です。
モチベーション
最近 TypeScript で Hono と Drizzle ORM を用いてバックエンド開発をしています。
最初のうちはシンプルでよかったものの、徐々に複雑になるにともなってレイヤーを整理してテストを書きやすくしたいよねという話が出てきました。
こういったときは DI (Dependency Injection: 依存性の注入) を使うとテストが書きやすくなります。
DI に関する詳しい説明は割愛しますが、 「実装に直接依存せずインタフェースに依存する」 ことでモック実装などに差し替えてテストを書くことができるようになります。
TypeScript で DI を行うためのライブラリとして有名どころでは InversifyJS や TSyringe
が存在します。
これらは @ijnectable() のようなデコレータを付与することで色々やってくれて便利なのですが、デコレータは仕組みをしっかりと理解していないとどこでどのような処理が行われているのかを追いづらいこともあり、もっと簡単な仕組みから始められないか検討しました。[1]
パターンの紹介
以下、下記のような UserRepository を getUser に注入することについて考えます。
// Repository インタフェース
interface IUserRepository {
findById(id: string): { id: string; name: string } | null;
}
// Repository 実装
class UserRepository implements IUserRepository {
findById(id: string) {
return { id, name: "Taro" };
}
}
const userRepository = new UserRepository();
// ここで IUserRepository に依存した何かしらの getUser() を呼び出す
1. Class Constructor パターン
クラスとして定義しコンストラクタで注入した依存を this を通して参照する実装です。
実装[2]
class UserService {
// ここで実装ではなくインタフェースに依存することがポイント
constructor(private userRepository: IUserRepository) {}
getUser(id: string) {
return this.userRepository.findById(id);
}
}
利用側
const userService = new UserService(userRepository);
const user = userService.getUser("1");
他のオブジェクト指向プログラミング言語でもよく見る形なので、DI といえばまずこの形を連想することが多いのではないでしょうか。
しかし TypeScript や JavaScript では多くの場合クラスはあまり使わず状態を持たない関数を定義することが好まれるため、DI のためにクラスを使っている感が否めません。
またこの方法のデメリットとして、必ずインスタンスのメソッドとして定義する必要があります。
1クラスに複数メソッドを定義する場合は問題になりませんが、1クラス1メソッドに分割しようとすると getUser.execute("1") ように冗長になってきます。
2. 関数DI (ファクトリー関数パターン)
クラスを使わない場合は以下のような「関数を返す関数」を使うと似たようなことができます。
実装
function createGetUser(deps: { userRepository : IUserRepository }) {
const { userRepository } = deps;
return (id: string) => {
return userRepository.findById(id);
}
}
利用側
const getUser = createGetUser(userRepository);
const user = getUser("1");
上記の例では単一の関数を返すファクトリー関数を定義していますが、複数の関数を返して createUserService のように定義してもいいという柔軟性があります。
慣れれば問題ないですが、なぜファクトリー関数なのかの理解を求められるのと、スコープには気をつける必要があります。
またもう少し型を厳密に当てたいとき、TypeScript で予めインタフェースを定義しておき戻り値の関数に適用しようとすると少し冗長な書き方になると思いました。[3]
3. 関数DI (第一引数パターン)
「実装に直接依存せずインタフェースに依存する」という目的は、ファクトリー関数として定義しないでも単に第一引数で依存を受け取ることでも実現可能です。
平易な関数なのでコード上もネストが浅く、TypeScript の型推論も安定します。
実装
function getUser(
deps: { userRepository: IUserRepository },
id: string
) {
const { userRepository } = deps;
return userRepository.findById(id);
}
利用側
最もシンプルにはこのように利用することができます。
const user = getUser({ userRepository }, "1")
毎回依存を引数に渡すのは大変なので、第一引数を固定化する高階関数を定義すると便利です。
function inject<Deps, Fn extends (deps: Deps, ...args: any[]) => any>(
deps: Deps,
fn: Fn
) {
return (...args: Parameters<Fn>) => fn(deps, ...args);
}
const injectedGetUser = inject({ userRepository }, getUser);
const user = injectedGetUser("1");
引数固定の実装方法は上記の inject 以外にも色々なパターンが考えられます。
実は カリー化 という操作を行うと前述のファクトリー関数パターンと同じ形になるため、定義が少し違うだけで本質的にはやっていることは同じです。
ところで Function.prototype.bind() を利用して以下のように引数を固定化することもできます。
const injectedGetUser = getUser.bind(null, { userRepository });
const user = injectedGetUser("1");
この書き方は Next.js で Server Functions に外部依存を注入する際 と同じ形ですね。[4]
JavaScript の標準機能でスッキリ書けるようにも思いますが、見ることの少ない機能のため多くの場合は inject 関数のようなユーティリティを別途定義したほうが見通しが良くなると思われます。
パターンの比較
以上3つの実装パターンを紹介しました。
紹介したパターンの亜種も含めて他にも様々なパターンがあるかもしれませんが、3つのパターンについて比較をまとめると以下の通りになります。
| 1. Class Constructor | 2. 関数DI(ファクトリー) | 3. 関数DI(第一引数) | |
|---|---|---|---|
| 実装のシンプルさ | △ | △ | ◎ |
| 利用側のシンプルさ | ◯ | ◎ | ◎ (inject後) |
| 型の扱いやすさ | ◎ | ○ | ◎ |
| 単機能への分割 | △ | ◎ | ◎ |
| 複数機能の集約 | ◎ | ◎ | △ |
| 向いているケース | 複数メソッドをまとめたい場合 | さまざまなケースに対応可能 | 小さな Usecase / Handler |
基本的には実装がシンプルな第一引数パターンから始めて1ファイル1機能に保つのが良さそうですが、それぞれのパターンに向き不向きがあるため、1つのパターンにこだわりすぎることなくプロジェクト内に複数のパターンが混在していても問題ないと思いました。
まとめ
TypeScript で Hono などのバックエンドに DI パターンを採用すると聞くとまずライブラリの検討からという印象を抱く人も多いと思いますが、ライブラリを使わずともシンプルなところから採用可能という紹介でした。
一方で実際にコードを書いていくと new や inject を繰り返し書く必要が出てきて、自動で注入してくれる DI コンテナ機能を持ったライブラリの偉大さを感じました。
最初から DI パターンの採用を見通すのであれば NestJS などのフレームワークに乗っかるのも良い選択肢だと思いました。
Discussion