インターフェースを切って、DIする理由
はじめに
本稿では、ソフトウェア開発におけるインターフェースの役割と、それを活用した DI(依存性の注入)という設計手法について解説します。インターフェースによって具体的な実装と切り離し、DI を用いて依存関係を管理することで、なぜプログラムの柔軟性や保守性が向上するのかを、具体的なコード例を交えて説明します。
プログラムは基本的には依存関係を持つ
プログラムのソースコードは基本的に依存関係を持ちます。一つのソースコード内に全てを書くことはなく、分割して参照するのが一般的です。
一般的に、「独立性が高いプログラム=保守性が高い」と言われますが、これは「結合度が低いプログラム=保守性が高い」と理解する方が適切です。完全に独立(どこからも参照されない)してしまうと、プログラムとしての意味をなさなくなるためです。
プログラムを利用するためには依存が必要ですが、依存性を高めない方が保守性は高いという、もどかしい側面があります。
依存関係を持つと何が面倒なのか?
例えば、プログラム A がプログラム B を参照して成り立っているとします。
これは A が B に依存している状態です。
A は B のプログラムの実装に依存しており、B の変更に対して A は影響を受けることになります。
↓ つまり
B を変更したら、A もテストしないと動作の担保ができません。
A と B が 1 対 1 であれば、あまり問題はありません。プログラム同士の関連度合いが高い場合、依存度合いが高くても大きく問題にはならないでしょう。
もし、B に依存するプログラムが 30 個あったらどうでしょうか?
その他の 30 のプログラムに関しても影響があり、すべてにテストを実施しないと動作が担保できません。
↓ つまり
多くのテストが必要となり、開発コストが増大します。
そこで有効になってくるのがインターフェース
インターフェースとは、具体的な処理を記述せず、どのようなメソッドやプロパティ(状態)を持つべきかという 契約(構造や型) だけを定義したものです。これにより、そのインターフェースを実装するクラスは、定義されたメソッドやプロパティを必ず持つことが保証されます。
実装の詳細はそれを実装するクラスに任されます。
よくあるインターフェースを利用した例(動物が鳴くプログラム)
interface Animal {
void makeSound();
}
class Dog implements Animal {
public void makeSound() {
System.out.println("Woof");
}
}
class Cat implements Animal {
public void makeSound() {
System.out.println("Meow");
}
}
public class Main {
public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();
myDog.makeSound(); // Outputs "Woof"
myCat.makeSound(); // Outputs "Meow"
}
}
インターフェースに依存すると何が良いのか?
インターフェースに依存すると、詳細ではなく抽象的なものに依存することになります。
↓ つまり
プログラム実装の詳細の影響を受けません。
これにより、実装がどうなっているかに関わらず、インターフェースで定義されたメソッドを通じてのみやりとりするため、実装の入れ替えや変更が発生した場合でも、その影響を局所化できます。これは、特定のクラスの変更が他の広範囲なコードに波及するのを防ぎ、結果としてテストの容易性や並行開発の促進、そしてシステムの柔軟性を高めることに繋がります。
プログラム内で利用している例
インターフェースには振る舞いを設定します。
import { User } from "../../dto/object/user.model";
import { UpdateUserInput } from "../../dto/input/update-user.input";
import { CreateUserInput } from "../../dto/input/create-user.input";
export interface IUserDao {
findById(id: number): Promise<User | null>;
create(data: CreateUserInput): Promise<User>;
update(data: UpdateUserInput): Promise<User>;
delete(id: number): Promise<User>;
}
export const IUserDaoToken = Symbol("IUserDao");
インターフェースを継承したプログラムには詳細を実装します。
import { Injectable } from '@nestjs/common';
import { IUserDao } from './interface/interface.user.dao';
import { PrismaService } from 'src/shared/prisma/prisma.service';
import { CreateUserInput } from '../dto/input/create-user.input';
import { UpdateUserInput } from '../dto/input/update-user.input';
import { User } from '../dto/object/user.model';
import { PaginationArgs } from 'src/shared/dto/args/pagination.args';
@Injectable()
export class UserDao implements IUserDao {
constructor(private readonly prisma: PrismaService) {}
async findById(id: number): Promise<User | null> {
return await this.prisma.user.findUnique({
where: {
id,
deletedAt: null,
},
});
}
~~~ 省略 ~~~
}
参照するユースケースサービス(インターフェースを参照しています)。
import { Inject, Injectable } from "@nestjs/common";
import { User } from "../dto/object/user.model";
import { IUserDao, IUserDaoToken } from "../dao/interface/interface.user.dao";
@Injectable()
export class GetUserUseCase {
constructor(@Inject(IUserDaoToken) private readonly userDao: IUserDao) {}
async execute(id: number): Promise<User | null> {
return await this.userDao.findById(id);
}
}
インターフェースに記載されているfindById(id: number): Promise<User | null>;
という情報以上の影響を受けません。
モジュール側で DI する(依存性の注入を行います)。
import { GetUserUseCase } from "./usecase/get-user.usecase";
import { IUserDaoToken } from "./dao/interface/interface.user.dao";
import { UserDao } from "./dao/user.dao";
@Module({
providers: [
GetUserUseCase,
{
provide: IUserDaoToken,
useClass: UserDao,
},
],
})
export class UserModule {}
IUserDaoToken
は DI コンテナにおける識別子であり、実際の実装クラス UserDao
をこの識別子で注入するように設定しています。TypeScript ではインターフェースがコンパイル時に型情報として削除されるため、DI コンテナがどの実装を注入すべきかを識別するためのシンボル(IUserDaoToken
)が必要になります。
インターフェース + DI(依存性の注入)の良いところ
入れ替えが容易になります。
ゲーム機のソフト(カセットや ROM、ダウンロードデータ)のように、簡単にプログラムの入れ替えが可能です。
例えば、現状では ORM は Prisma を利用していますが、TypeORM に変更する場合などは、以下のような形になります。
IUserDao
を継承した UserTypeORMDao
を作成します。
import { Injectable } from "@nestjs/common";
import { IUserDao } from "./interface/interface.user.dao";
import { Repository } from "typeorm";
import { InjectRepository } from "@nestjs/typeorm";
import { CreateUserInput } from "../dto/input/create-user.input";
import { UpdateUserInput } from "../dto/input/update-user.input";
import { User } from "../dto/object/user.model";
@Injectable()
export class UserTypeORMDao implements IUserDao {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>
) {}
async findById(id: number): Promise<User | null> {
return await this.userRepository.findOne({
where: {
id,
deletedAt: null,
},
});
}
~~~ 省略 ~~~
}
モジュール側で DI を UserTypeORMDao
に変更します。
import { GetUserUseCase } from "./usecase/get-user.usecase";
import { IUserDaoToken } from "./dao/interface/interface.user.dao";
import { UserTypeORMDao } from "./dao/user-typeorm.dao";
@Module({
providers: [
GetUserUseCase,
{
provide: IUserDaoToken,
useClass: UserTypeORMDao, // 変更
},
],
})
export class UserModule {}
まとめ
- プログラムは必然的に依存関係を伴います。
- 強い依存関係は、変更が広範囲に波及し、保守性を低下させる原因となります。
- インターフェースは、具体的な実装から抽象的な契約へと依存関係を逆転させることで、プログラムの詳細への依存を排除します。
- インターフェースと DI(依存性の注入)を組み合わせることで、システムの疎結合化と柔軟なモジュール交換が可能になります。
Discussion