📑

インターフェースを切って、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(依存性の注入)を組み合わせることで、システムの疎結合化柔軟なモジュール交換が可能になります。
H&Companyテックブログ

Discussion