技術 約8分で読めます

「.envで切り替えればいいじゃん」に答えるPHP DIコンテナ入門

そもそも、なぜ依存を差し替えたいのか

現代のプログラムは1ファイルに全部書くわけじゃない。好き嫌いは置いといて、オブジェクト指向が多数派だから、どうしてもクラス間の関係性が複雑になる。

OrderService → UserService → UserRepository → PDO
            → PaymentService → PaymentGateway
            → NotificationService → MailClient

この複雑な依存関係を「ぶった切って」、各クラスを単体でテスト可能にしたい。

// 密結合: UserServiceのテストにMySQLが必要
class UserService
{
    private MySQLUserRepository $repository;

    public function __construct()
    {
        $pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'password');
        $this->repository = new MySQLUserRepository($pdo);
    }
}

これだと UserService のテストを動かすのに本物のMySQLが必要になる。CIで動かすのも面倒だし、テストのたびにDBの状態を気にしないといけない。

// 疎結合: テストではInMemoryRepositoryを渡せばいい
class UserService
{
    public function __construct(
        private UserRepositoryInterface $repository
    ) {}
}

インターフェースで依存を受け取るようにすれば、テスト時は InMemoryUserRepository を渡せばいい。DBなしで動く、速い、テストケースごとに状態をリセットできる。

これが疎結合の根本的なメリット。 テストデータさえあれば動くか動かないかわかる状態にできる。ユニットテスト、結合テスト、E2E、どれもやりやすくなる。

で、問題は「どうやって差し替えるか」。

「.envで切り替えればいいじゃん」

DIコンテナの話をすると、こう言われることがある。

「本番とテストで実装変えたいなら、.envで分岐すればよくない?」

確かに、できる。でも実際にやってみると地獄が待っている。

.envで頑張る場合の地獄

条件分岐が全部に散らばる

class UserService
{
    private UserRepositoryInterface $repository;

    public function __construct()
    {
        if (getenv('APP_ENV') === 'test') {
            $this->repository = new InMemoryUserRepository();
        } else {
            $pdo = new PDO(
                getenv('DB_DSN'),
                getenv('DB_USER'),
                getenv('DB_PASS')
            );
            $this->repository = new MySQLUserRepository($pdo);
        }
    }
}

UserRepository を使うクラスが10個あったら、この分岐を10箇所に書くのか?

依存が増えるたびに全箇所修正

CacheInterface を追加したい。また全クラスに分岐を追加?

public function __construct()
{
    // UserRepository の分岐...
    if (getenv('APP_ENV') === 'test') {
        $this->repository = new InMemoryUserRepository();
    } else {
        // ...
    }

    // Cache の分岐も追加...
    if (getenv('APP_ENV') === 'test') {
        $this->cache = new ArrayCache();
    } else {
        $this->cache = new RedisCache(getenv('REDIS_HOST'));
    }
}

もう嫌になってきた。

依存の依存で詰む

現実のアプリは依存が深い。

OrderService
  → UserService
    → UserRepository
      → PDO

これを手動で組み立てると:

$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));
$userRepo = new MySQLUserRepository($pdo);
$userService = new UserService($userRepo);
$orderService = new OrderService($userService);

これを OrderService が必要な箇所全部で書く?

テストで UserRepository だけ差し替えたいとき、全部書き直し?

「configファイルにまとめればいいじゃん」

「じゃあconfigファイルに集約すれば、散らばる問題は解決するのでは?」

// config.php
function createPdo(): PDO
{
    static $pdo = null;
    if ($pdo === null) {
        $pdo = new PDO(
            getenv('DB_DSN'),
            getenv('DB_USER'),
            getenv('DB_PASS')
        );
    }
    return $pdo;
}

function createUserRepository(): UserRepositoryInterface
{
    if (getenv('APP_ENV') === 'test') {
        return new InMemoryUserRepository();
    }
    return new MySQLUserRepository(createPdo());
}

function createUserService(): UserService
{
    return new UserService(createUserRepository());
}

function createOrderService(): OrderService
{
    return new OrderService(createUserService());
}

確かに分岐は1箇所にまとまった。でも:

  • クラス追加のたびにconfig修正: PaymentService を作ったら createPaymentService() を追加
  • 依存関係変更のたびにconfig修正: UserService のコンストラクタに CacheInterface を追加したら、createUserService() も修正
  • シングルトン管理が手動: static $pdo みたいなのを自分で書く
  • 「何が何に依存してるか」を人間が全部把握し続ける

結局、依存関係のグラフを人間が手動で管理し続けることになる。

DIコンテナなら、型さえ合っていれば自動で解決される。新しいクラスを追加しても、コンストラクタの引数が変わっても、バインド設定さえあれば動く。

「namespaceを書くなら一緒じゃない?」

「DIコンテナでもバインド設定でクラス名書くんでしょ?configに書くのと変わらなくない?」

書く量が全然違う。

configファイル方式:

// 全クラス分のファクトリを書く
function createPdo(): PDO { ... }
function createUserRepository(): UserRepositoryInterface { ... }
function createUserService(): UserService { ... }
function createOrderService(): OrderService { ... }
function createPaymentService(): PaymentService { ... }
function createNotificationService(): NotificationService { ... }
// クラスが増えるたびに増殖...

DIコンテナ:

// インターフェース → 実装 のマッピングだけ
$container->singleton(PDO::class, fn() => new PDO(...));
$container->singleton(UserRepositoryInterface::class, MySQLUserRepository::class);
$container->singleton(CacheInterface::class, RedisCache::class);
// 具象クラス同士の依存は書かなくていい

UserServiceOrderService のバインド設定は不要。コンストラクタの型を見て自動解決されるから。

バインドが必要なのは:

  • インターフェース → 実装クラスのマッピング
  • 特殊な初期化が必要なもの(PDOの接続情報など)

これだけ。具象クラス同士の依存関係は、DIコンテナが勝手にやってくれる。

さらに言えば、::class は文字列じゃなくてPHPの言語機能:

UserRepositoryInterface::class  // ただの文字列じゃない
MySQLUserRepository::class
  • IDEの補完が効く
  • クラス名を変更したらリファクタリングで追従する
  • 存在しないクラスを書いたらエラーになる

configのファクトリ関数名 createUserService は完全に文字列ベースだから、こういった恩恵は受けられない。

そしてコンストラクタインジェクションを使う側は、そもそも追加のnamespace記述すら不要:

use App\Repository\UserRepositoryInterface;

class UserService
{
    public function __construct(
        private UserRepositoryInterface $repository
    ) {}
}

use で既にインポートしてるから、依存を受け取るための特別なコードは何もいらない。普通にクラスを書くだけで依存関係が宣言される。DIコンテナが型を見て勝手に解決してくれる。

DIコンテナが解決すること

DIコンテナを使うと、設定は1箇所で済む。

// bootstrap.php(アプリ起動時に1回だけ)
$container = DIContainer::getInstance();
$container->singleton(PDO::class, fn() => new PDO(
    getenv('DB_DSN'),
    getenv('DB_USER'),
    getenv('DB_PASS')
));
$container->singleton(UserRepositoryInterface::class, MySQLUserRepository::class);

使う側はこれだけ:

$orderService = $container->make(OrderService::class);

OrderServiceUserServiceUserRepositoryPDO の依存関係は、全部自動で解決される

なぜ自動で解決できるのか

DIコンテナの核心は「Reflectionで型を見て再帰的に解決する」こと。

ステップ1: コンストラクタの引数を調べる

$reflector = new ReflectionClass(UserService::class);
$constructor = $reflector->getConstructor();
$parameters = $constructor->getParameters();
// → UserRepositoryInterface が必要だとわかる

PHPのReflection APIを使うと、クラスのコンストラクタが「何を引数に取るか」がわかる。

ステップ2: 引数の型がクラスなら再帰的に解決

foreach ($parameters as $param) {
    $type = $param->getType();

    if (!$type->isBuiltin()) {
        // string, int などのビルトイン型でなければ
        // クラスかインターフェースなので、再帰的に make() する
        $dependencies[] = $this->make($type->getName());
    }
}

UserRepositoryInterface が必要? → バインド設定を見る → MySQLUserRepository だ → それも make() する → PDO が必要 → …

ステップ3: 全部揃ったらnew

return $reflector->newInstanceArgs($dependencies);

依存が全部解決できたら、newInstanceArgs() でインスタンス生成。

たったこれだけ。 型情報を見て、必要なものを再帰的に作って、渡す。

実践:リポジトリパターンでの切り替え

インターフェースを定義

interface UserRepositoryInterface
{
    public function find(int $id): ?User;
    public function save(User $user): void;
}

本番用の実装

class MySQLUserRepository implements UserRepositoryInterface
{
    public function __construct(private PDO $pdo) {}

    public function find(int $id): ?User
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch();
        return $row ? new User($row['id'], $row['name']) : null;
    }

    public function save(User $user): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (id, name) VALUES (?, ?)'
        );
        $stmt->execute([$user->id, $user->name]);
    }
}

テスト用の実装

class InMemoryUserRepository implements UserRepositoryInterface
{
    private array $users = [];

    public function find(int $id): ?User
    {
        return $this->users[$id] ?? null;
    }

    public function save(User $user): void
    {
        $this->users[$user->id] = $user;
    }
}

使う側は実装を知らない

class UserService
{
    public function __construct(
        private UserRepositoryInterface $repository
    ) {}

    public function getUser(int $id): ?User
    {
        return $this->repository->find($id);
    }
}

UserServiceMySQLUserRepositoryInMemoryUserRepository も知らない。インターフェースだけ知っている。

環境ごとの設定

// 本番環境 bootstrap.php
$container->singleton(
    UserRepositoryInterface::class,
    MySQLUserRepository::class
);

// テスト環境 tests/bootstrap.php
$container->singleton(
    UserRepositoryInterface::class,
    InMemoryUserRepository::class
);

バインド設定を1行変えるだけで、アプリ全体の UserRepositoryInterface が差し替わる。

まとめ

「.envで切り替えればいい」の問題点:

  • 条件分岐が依存を使う全箇所に散らばる
  • 依存が増えるたびに全箇所修正
  • 依存の依存(A→B→C→D)を手動で組み立てる地獄

「configファイルにまとめればいい」の問題点:

  • クラス追加のたびにファクトリ関数を追加
  • 依存関係が変わるたびにファクトリ関数を修正
  • 結局、依存グラフを人間が全部管理し続ける

DIコンテナが解決すること:

  • 設定は1箇所(bootstrap)
  • 使う側は $container->make() するだけ
  • 依存の依存もReflectionで自動解決

核心は「型を見て再帰的に解決」。これだけ覚えておけば、DIコンテナが何をしているかは理解できる。

補足:パターンに縛られすぎない

リポジトリパターンやMVCにこだわりすぎて、ファイル数が増えまくるのも考えもの。機能が小さいなら、サービスクラスやコントローラに直接書いてしまっても別にいい。

大事なのは「動作チェックで詰まる」という本末転倒を避けること。

  • 外部APIを叩く部分だけ差し替え可能にする
  • DBアクセスが絡む部分だけインターフェースを切る

全部に適用する必要はない。「ここはテストで困りそうだな」というところだけ疎結合にすればいい。アーキテクチャのためにテストがあるんじゃなくて、テストのためにアーキテクチャがある。