【初学者向け】依存性注入(DI)をTypeScriptの実装例ベースで理解しよう
公開日2025.01.09
依存性注入とは
適切に機能するソフトウェアを効率的に提供するために、最も重要な要素の1つが「保守容易性」です。この保守容易性を高めるための優れた方法の1つが「疎結合な設計」であり、その実現手段として「依存性注入(Dependency Injection: DI)」があります。
依存性注入とは、あるクラスが他のクラスやサービス(依存関係)を必要とする際に、その依存関係をクラス内部で直接生成するのではなく、外部から提供する設計手法のことです。
シンプルなユーザー登録システムの例を使って、DIの概念と利点を解説していきます。
密結合な実装の問題点
まず、密結合な実装の例を見てみましょう
// 密結合な実装
class UserService {
// クラス内部で直接インスタンス化(密結合)
private userRepository = new UserRepository();
private passwordHasher = new PasswordHasher();
async register(email: string, password: string) {
// ユーザーの重複チェック
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new Error('既に登録済みのメールアドレスです');
}
// パスワードのハッシュ化とユーザー保存
const hashedPassword = await this.passwordHasher.hash(password);
await this.userRepository.save({
email,
hashedPassword
});
}
}
// 使用例
const userService = new UserService();
await userService.register('user@example.com', 'password123');
密結合な実装の主な問題点
- テストが困難
- UserRepositoryやPasswordHasherをモックに置き換えられない
- 実際のデータベースやハッシュ処理が必要になる
- 変更に弱い
- データベースをMySQLからMongoDBに変更する場合、UserServiceの修正が必要になります
- この場合、userServiceの責務ではないのにUserServiceのコード自体を修正する必要があり、修正による影響範囲が大きくなってしまいます
依存性注入を用いた疎結合な実装
これを依存性注入を使って改善してみましょう
// 1. インターフェース定義
interface IUserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface IPasswordHasher {
hash(password: string): Promise<string>;
}
// 2. ユーザー登録サービス(依存を注入される側)
class UserService {
// コンストラクタで依存関係を注入
constructor(
private userRepository: IUserRepository,
private passwordHasher: IPasswordHasher
) {}
async register(email: string, password: string) {
// ユーザーの重複チェック
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new Error('既に登録済みのメールアドレスです');
}
// パスワードのハッシュ化とユーザー保存
const hashedPassword = await this.passwordHasher.hash(password);
await this.userRepository.save({
email,
hashedPassword
});
}
}
// 3. 具体的な実装
class MySQLUserRepository implements IUserRepository {
async findByEmail(email: string) { /* MySQL用の実装 */ }
async save(user: User) { /* MySQL用の実装 */ }
}
class BcryptPasswordHasher implements IPasswordHasher {
async hash(password: string) { /* bcryptでのハッシュ化 */ }
}
// 4. 使用例
const userService = new UserService(
new MySQLUserRepository(),
new BcryptPasswordHasher()
);
await userService.register('user@example.com', 'password123');
依存性注入のメリット
- テストが容易になる
// テスト用のモック
class MockUserRepository implements IUserRepository {
private users: User[] = [];
async findByEmail(email: string) {
return this.users.find(u => u.email === email) || null;
}
async save(user: User) {
this.users.push(user);
}
}
// テストコード
describe('UserService', () => {
it('should register new user', async () => {
const userService = new UserService(
new MockUserRepository(),
new MockPasswordHasher()
);
await userService.register('test@example.com', 'password');
// テストアサーション
});
});
- 実装の変更が容易
// MongoDBに変更する場合は新しいクラスを作るだけ
class MongoUserRepository implements IUserRepository {
async findByEmail(email: string) { /* MongoDB用の実装 */ }
async save(user: User) { /* MongoDB用の実装 */ }
}
// 使用時に差し替えるだけ
const userService = new UserService(
new MongoUserRepository(), // MySQLからMongoDBに変更
new BcryptPasswordHasher()
);
- インターフェースに依存することのメリット
- 実装の詳細から切り離される
// インターフェースのみに依存 class UserService { constructor(private userRepository: IUserRepository) {} async register(email: string, password: string) { // インターフェースで定義されたメソッドのみを使用 // → 具体的な実装(MySQL/MongoDB)を意識する必要がない const existingUser = await this.userRepository.findByEmail(email); // ... } }
- 得られるメリット
- 抽象化による理解のしやすさ:具体的な実装を意識せずにビジネスロジックに集中できる
- 実装の切り替えが容易:新しいデータベースに対応する際も、インターフェースさえ満たせばOK
- テストの簡素化:インターフェースに基づいたモックの作成が容易
- コードの再利用性:異なるプロジェクトでも同じインターフェースを使い回せる
まとめ
依存性注入(DI)を使うことで、以下のような利点が得られます
- テストの容易性:モックを使用した単体テストが簡単になる
- 変更への強さ:実装の変更が他のコードに影響を与えにくい
- 再利用性の向上:インターフェースを介することで、コードの再利用が容易になる
DIを活用することで、より保守性の高い、品質の良いソフトウェアを開発することができますのでためしてみてください(`・ω・´)