「.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);
// 具象クラス同士の依存は書かなくていい
UserService や OrderService のバインド設定は不要。コンストラクタの型を見て自動解決されるから。
バインドが必要なのは:
- インターフェース → 実装クラスのマッピング
- 特殊な初期化が必要なもの(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);
OrderService → UserService → UserRepository → PDO の依存関係は、全部自動で解決される。
なぜ自動で解決できるのか
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);
}
}
UserService は MySQLUserRepository も InMemoryUserRepository も知らない。インターフェースだけ知っている。
環境ごとの設定
// 本番環境 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アクセスが絡む部分だけインターフェースを切る
全部に適用する必要はない。「ここはテストで困りそうだな」というところだけ疎結合にすればいい。アーキテクチャのためにテストがあるんじゃなくて、テストのためにアーキテクチャがある。