はじめに
業務でコードを読んでいると、@Autowiredや「DI」という言葉をよく見かけました。調べていくうちに「依存性の注入」という設計パターンにたどり着いたので、自分の理解を整理するためにまとめてみました。
依存性の注入とは
一言でいうと、
オブジェクトが必要とするものを、自分で作るのではなく外から受け取る
という設計パターンです。
「依存性」とは「そのクラスが動くために必要な別のオブジェクト」のことです。
まず「注入なし」のコードを見てみよう
たとえば、ユーザー情報を取得するUserServiceというクラスを作るとします。
class UserRepository {
findById(id: number) {
// データベースからユーザーを取得する処理
return { id, name: "田中太郎" };
}
}
class UserService {
private repository: UserRepository;
constructor() {
this.repository = new UserRepository(); // 自分でインスタンスを生成している
}
getUser(id: number) {
return this.repository.findById(id);
}
}
const service = new UserService();
console.log(service.getUser(1));
一見問題なさそうですが、これには大きな欠点があります。
何が問題なのか
問題1:テストしにくい
UserServiceの中でnew UserRepository()しているので、テスト時に本物のデータベースが必要になってしまいます。
// テストしたい
const service = new UserService();
// UserRepositoryが内部で固定されているので差し替えられない!
問題2:柔軟性がない
たとえば本番環境ではPostgreSQL、開発環境ではインメモリDB、というように実装を切り替えたい場面が出てきます。しかし内部でnewしていると簡単には差し替えられません。
問題3:密結合になる
UserServiceがUserRepositoryの具体的な実装に強く依存してしまいます。UserRepositoryを変更するとUserServiceも壊れるリスクがあります。
依存性の注入で解決する
コンストラクタの引数として外からUserRepositoryを渡すように変えます。
class UserRepository {
findById(id: number) {
return { id, name: "田中太郎" };
}
}
class UserService {
private repository: UserRepository;
constructor(repository: UserRepository) { // 外から受け取る
this.repository = repository;
}
getUser(id: number) {
return this.repository.findById(id);
}
}
// 使う側がインスタンスを作って渡す
const repository = new UserRepository();
const service = new UserService(repository); // 注入!
console.log(service.getUser(1));
たったこれだけで、誰がUserRepositoryを作るかの責任がUserServiceの外に移りました。
テストが簡単になる
差し替えが自由になるので、テスト時にはモック(偽物)を渡せます。
// テスト用の偽リポジトリ
class MockUserRepository {
findById(id: number) {
return { id, name: "テストユーザー" }; // 固定値を返すだけ
}
}
// 本物の代わりにモックを注入
const service = new UserService(new MockUserRepository());
console.log(service.getUser(1)); // DBなしでテストできる!
データベースなしでテストできるようになりました。
インターフェースを使うともっと柔軟になる
TypeScriptではインターフェースを組み合わせることで、より疎結合な設計ができます。
// インターフェース(約束事)を定義
interface IUserRepository {
findById(id: number): { id: number; name: string };
}
// 本番用の実装
class UserRepository implements IUserRepository {
findById(id: number) {
// 本物のDBから取得
return { id, name: "田中太郎" };
}
}
// 開発用の実装
class InMemoryUserRepository implements IUserRepository {
findById(id: number) {
// メモリ上のデータを返す
return { id, name: "開発用ユーザー" };
}
}
class UserService {
private repository: IUserRepository; // インターフェース型で受け取る
constructor(repository: IUserRepository) {
this.repository = repository;
}
getUser(id: number) {
return this.repository.findById(id);
}
}
// 本番環境
const prodService = new UserService(new UserRepository());
// 開発環境
const devService = new UserService(new InMemoryUserRepository());
UserServiceはインターフェース(IUserRepository)にしか依存していないので、具体的な実装がどう変わっても影響を受けません。
まとめ
| 注入なし | 注入あり | |
|---|---|---|
| インスタンスの生成 | 自分でnew | 外から受け取る |
| テストのしやすさ | ❌ 難しい | ✅ 簡単 |
| 実装の切り替え | ❌ 難しい | ✅ 簡単 |
| 結合度 | 密結合 | 疎結合 |
依存性の注入のポイントをまとめると:
- 「自分でnewしない」が基本の考え方
- 外から受け取ることで、差し替えが自由になる
- インターフェースと組み合わせるとさらに効果的
- テストしやすいコードが自然と書けるようになる
最初は「引数で渡せばいいだけ?」と思うかもしれませんが、この小さな工夫がプロジェクト規模が大きくなるほど大きな差を生みます。ぜひ意識してみてください!