#09 オブジェクト指向プログラミング入門

依存性注入——テストしやすく柔軟なコードを書く

「依存性」とは何か

あるクラスが別のクラスを必要とするとき、「依存している」と言います。

class OrderService
{
    public function placeOrder(array $items): void
    {
        // メール送信のために Mailer に依存している
        $mailer = new Mailer(); // 依存するクラスを自分で作っている
        $mailer->send('order@example.com', '注文確定', '注文が確定しました。');
    }
}

OrderServiceMailer を内部で new しています。これは一見問題ないように見えますが、いくつかの問題があります。


依存を内部で作る問題点

テストが難しい

単体テストでは「本物のメールを送らずにテストしたい」はずです。しかし new Mailer() が内部に埋め込まれていると、メールサーバーなしでテストできません。

柔軟性がない

開発環境ではログに出力、本番環境では実際に送信……という切り替えができません。

変更の影響が広がる

Mailer クラスのコンストラクタ引数が変わったとき、new Mailer() している全箇所を修正しなければなりません。


依存性注入(DI)の解決策

依存するオブジェクトを「外から渡してもらう」のが依存性注入(Dependency Injection)です。

// OK: 依存を外から受け取る
class OrderService
{
    public function __construct(
        private MailerInterface $mailer // インターフェースに依存
    ) {}

    public function placeOrder(array $items): void
    {
        $this->mailer->send('order@example.com', '注文確定', '注文が確定しました。');
    }
}
interface MailerInterface
{
    public function send(string $to, string $subject, string $body): void;
}

// 本番用:実際にメールを送る
class SmtpMailer implements MailerInterface
{
    public function send(string $to, string $subject, string $body): void
    {
        // SMTPサーバーにメールを送信
        mail($to, $subject, $body);
    }
}

// テスト用:ログに記録するだけ
class FakeMailer implements MailerInterface
{
    public array $sentEmails = [];

    public function send(string $to, string $subject, string $body): void
    {
        $this->sentEmails[] = compact('to', 'subject', 'body');
    }
}

使い方

// 本番環境
$service = new OrderService(new SmtpMailer());
$service->placeOrder([...]);

// テスト環境
$fakeMailer = new FakeMailer();
$service = new OrderService($fakeMailer);
$service->placeOrder([...]);

// メールが送られたか確認できる
assert(count($fakeMailer->sentEmails) === 1);
assert($fakeMailer->sentEmails[0]['to'] === 'order@example.com');

コンストラクタインジェクション

依存性を渡す方法のうち最もよく使われるのがコンストラクタインジェクションです。コンストラクタの引数で依存を受け取ります。

class UserService
{
    public function __construct(
        private UserRepositoryInterface $userRepository,
        private MailerInterface $mailer,
        private LoggerInterface $logger,
    ) {}

    public function register(string $name, string $email, string $password): User
    {
        if ($this->userRepository->findByEmail($email) !== null) {
            throw new \RuntimeException('このメールアドレスはすでに使用されています。');
        }

        $user = new User(
            id: 0,
            name: $name,
            email: $email,
            password: password_hash($password, PASSWORD_BCRYPT),
        );

        $this->userRepository->save($user);
        $this->mailer->send($email, 'ご登録ありがとうございます', 'アカウント登録が完了しました。');
        $this->logger->info("新規ユーザー登録: {$email}");

        return $user;
    }
}

コンストラクタを見るだけで「このクラスが何に依存しているか」が一目でわかります。


その他の注入方法

セッターインジェクション

オプションの依存性に向いています。

class OrderService
{
    private ?LoggerInterface $logger = null;

    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }

    public function placeOrder(array $items): void
    {
        // ロガーがあればログを記録
        $this->logger?->info('注文処理開始');
        // ...
    }
}

DIコンテナ入門

依存が複数階層になると、手動で new して渡すのが煩雑になります。

// 手動でやると大変...
$logger     = new FileLogger('/var/log/app.log');
$mailer     = new SmtpMailer('smtp.example.com', 587, $logger);
$userRepo   = new DatabaseUserRepository($pdo, $logger);
$userService = new UserService($userRepo, $mailer, $logger);

DIコンテナはこの依存の解決を自動化してくれるツールです。

シンプルなDIコンテナの仕組み

class Container
{
    private array $bindings = [];

    // 「このインターフェースを要求されたらこれを返す」と登録
    public function bind(string $abstract, callable $factory): void
    {
        $this->bindings[$abstract] = $factory;
    }

    // インスタンスを取得
    public function make(string $abstract): mixed
    {
        if (isset($this->bindings[$abstract])) {
            return ($this->bindings[$abstract])($this);
        }
        throw new \RuntimeException("{$abstract} は登録されていません。");
    }
}
$container = new Container();

$container->bind(LoggerInterface::class, fn() => new FileLogger('/var/log/app.log'));

$container->bind(MailerInterface::class, fn(Container $c) =>
    new SmtpMailer('smtp.example.com', 587, $c->make(LoggerInterface::class))
);

$container->bind(UserRepositoryInterface::class, fn(Container $c) =>
    new DatabaseUserRepository($pdo, $c->make(LoggerInterface::class))
);

$container->bind(UserService::class, fn(Container $c) => new UserService(
    $c->make(UserRepositoryInterface::class),
    $c->make(MailerInterface::class),
    $c->make(LoggerInterface::class),
));

// 使うときは make() を呼ぶだけ
$userService = $container->make(UserService::class);

Laravelの app()->make()app(UserService::class) はまさにこの仕組みです。


テスタビリティが上がる理由

DIを使うとテストが格段に書きやすくなります。

use PHPUnit\Framework\TestCase;

class UserServiceTest extends TestCase
{
    public function test_register_sends_email(): void
    {
        // 偽物の依存を用意する(モック)
        $fakeRepo   = new InMemoryUserRepository();
        $fakeMailer = new FakeMailer();
        $fakeLogger = new NullLogger(); // ログは捨てる

        $service = new UserService($fakeRepo, $fakeMailer, $fakeLogger);
        $user = $service->register('田中太郎', 'tanaka@example.com', 'password');

        // メールが送られたか確認
        $this->assertCount(1, $fakeMailer->sentEmails);
        $this->assertSame('tanaka@example.com', $fakeMailer->sentEmails[0]['to']);

        // ユーザーが保存されたか確認
        $saved = $fakeRepo->findByEmail('tanaka@example.com');
        $this->assertNotNull($saved);
        $this->assertSame('田中太郎', $saved->name);
    }
}

メールサーバーもデータベースも使わず、UserService の本質的なロジックだけをテストできます。


まとめ

  • 依存とは「あるクラスが他のクラスを必要とする関係」
  • 依存を内部で new すると、テストが難しく柔軟性がなくなる
  • コンストラクタインジェクションで外から依存を渡す
  • インターフェースに依存させることで、実装を差し替えられる
  • DIコンテナは複雑な依存解決を自動化してくれる
  • DIにより、偽物(フェイク・モック)を使った単体テストが書きやすくなる

次回はいよいよ最終回。SOLID原則とオブジェクト指向設計の全体像をまとめます。