Facadeを使わずDIで書くと何が変わるか — 見える依存と使ってよい場所
この記事で扱う問題
Laravel の Facade は、Cache::forget() や Log::info() のように、どこからでも1行で呼べます。now()・config()・app() のようなヘルパーも、中身はコンテナから取り出したインスタンスを呼んでいるだけで、使い勝手は Facade と同じです。
例えば、注文を確定する UseCase を Facade とヘルパーで書くと、次のようになります。
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
final class ConfirmOrder
{
public function execute(OrderId $id, int $operatorId): ConfirmOrderResult
{
return DB::transaction(function () use ($id, $operatorId) {
$orders = app(OrderRepository::class);
$order = $orders->findOrFail($id);
$order->confirm($operatorId, now()->toImmutable());
$orders->save($order);
RequestShipmentJob::dispatch($id->value)->afterCommit();
Cache::forget("order-detail:{$id->value}");
Log::info('order confirmed', ['order_id' => $id->value]);
return ConfirmOrderResult::from($order);
});
}
}
短く、動きます。Queue::fake() や $this->travelTo() があるので、テストも書けます。それでも、例えば次のことが起こり得ます。
- 何に触れるクラスかが、外から見えない:このクラスが DB・キュー・キャッシュ・ログ・時計に触れることは、メソッドの中を最後まで読まないと分かりません
- 依存が増えても、誰も気づかない:
Cache::やMail::の行は1行ずつ足されるので、責務が増えていることが差分から目立ちません - テストで差し替え忘れたものが、本物に届く:Facade の fake はアプリ全体の差し替えです。どの Facade を差し替えるべきかは本体を読んで知るしかなく、例えば外部 API を呼ぶ処理で
Http::fake()を忘れれば、テストから本物の相手にリクエストが届きます - 業務の手順が Laravel なしでは動かない:
app()やDB::transaction()があるので、このクラスのテストには Laravel の起動と DB が要ります
判断
私は、Facade とヘルパーを使う場所を層で分けます。
| 場所 | Facade・ヘルパー | 依存の受け取り方 |
|---|---|---|
ドメイン(Order・Value Object) |
使わない | 依存を持たない。時刻のような値も引数で受け取る |
| UseCase | 使わない | アプリで定義した Interface をコンストラクタで受け取る |
| Infrastructure(Repository や Gateway の実装) | 使ってもよい | Laravel のコントラクトをコンストラクタで受け取るのを基本にする |
| 入口(Controller・コマンド・ジョブ・ミドルウェア) | HTTP やコンソールの都合(response()・abort())には使う |
業務の依存はコンストラクタか handle() の引数で受け取る |
組み立て(サービスプロバイダ・bootstrap/app.php)とテスト |
使う | ここは Laravel に依存すること自体が役割 |
線を引いているのは、業務の手順を書く場所(UseCase とドメイン)だけです。Laravel の機能を使う外側では、Facade を避ける理由はあまりありません。
UseCase は、コンストラクタに依存を並べる
冒頭の UseCase に当たるものを、これまでの記事では DI で書いてきました。在庫の引き当てとドメインイベントまで足した、ドメインイベントの記事の ConfirmOrder です。execute() の中身はその記事の定義のままで、ここではコンストラクタだけを再掲します。
final class ConfirmOrder
{
public function __construct(
private OrderRepository $orders,
private StockAllocator $stocks,
private TransactionManager $transaction,
private Clock $clock,
private ShipmentRequestQueue $shipmentRequests,
private DomainEventPublisher $events,
) {
}
// execute() と allocateStocks() は既存の定義のまま(省略)
}
この6行が、このクラスが触れるものの一覧です。どれも Laravel のクラスではなく、アプリの側で業務の言葉で定義した Interface です。キューは Dispatcher ではなく「出荷を依頼する」ShipmentRequestQueue、キャッシュの削除やメールは個別の依存ではなく「起きたことを配る」DomainEventPublisher です。
時計とトランザクションも、Interface にする
now() と DB::transaction() は、業務の手順の中で一番よく使うヘルパーと Facade です。この2つにも、アプリの Interface を用意します。
use DateTimeImmutable;
interface Clock
{
public function now(): DateTimeImmutable;
}
/** 本番の時計。Infrastructure なので、ここでは now() を使う */
final class SystemClock implements Clock
{
public function now(): DateTimeImmutable
{
return now()->toImmutable();
}
}
/** テストの時計。渡した時刻を返し続ける */
final class FixedClock implements Clock
{
public function __construct(private DateTimeImmutable $now)
{
}
public function now(): DateTimeImmutable
{
return $this->now;
}
}
SystemClock の中で now() を使っているのは、上の表のとおり Infrastructure だからです。new DateTimeImmutable() で書くと、Feature Test で $this->travelTo() を使って時刻を止めても、この時計だけは止まりません。
形は PSR-20 の ClockInterface と同じなので、そちらを使っても構いません。私は既存のコードに合わせて、自前の Clock にしています。
use Closure;
use Illuminate\Database\ConnectionInterface;
interface TransactionManager
{
/**
* @template T
* @param callable(): T $operation
* @return T
*/
public function run(callable $operation): mixed;
}
final class DatabaseTransactionManager implements TransactionManager
{
// コンテナが渡すのは既定の DB 接続。afterCommit() で配る実装や Outbox も、同じ接続を受け取る
public function __construct(private ConnectionInterface $db)
{
}
public function run(callable $operation): mixed
{
return $this->db->transaction(Closure::fromCallable($operation));
}
}
Outbox の記事で書いたとおり、Laravel の after_commit は Laravel の DB 接続で始めたトランザクションしか追いかけません。DatabaseTransactionManager が接続の transaction() を使っているのは、そのためです。
どの Interface にどの実装を使うかは、サービスプロバイダの1か所に書きます。
use Illuminate\Support\ServiceProvider;
final class InfrastructureServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Clock::class, SystemClock::class);
$this->app->bind(TransactionManager::class, DatabaseTransactionManager::class);
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
$this->app->bind(StockAllocator::class, DatabaseStockAllocator::class);
$this->app->bind(ShipmentRequestQueue::class, OutboxShipmentRequestQueue::class);
}
}
(DomainEventPublisher は、ドメインイベントの記事の DomainEventServiceProvider で束縛しています。このプロバイダも、Laravel 11 以降なら bootstrap/providers.php に足して登録します。)
注入しないもの:リクエストごとに変わる値
コンストラクタで受け取るのは、リクエストをまたいで同じでよいものだけです。Request やログイン中の利用者は注入せず、execute() の引数で渡します。ConfirmOrder が操作した人を int $operatorId として受け取っているのは、そのためです。
これが効いてくるのは、Octane やキューのワーカーのように、アプリが常駐する環境です。PHP-FPM ではシングルトンもリクエストごとに作り直されますが、常駐する環境では、シングルトンのコンストラクタに Request を渡すと、最初のリクエストのインスタンスを持ち続けます。また、ジョブのコンストラクタはペイロードとして保存されるので、ジョブの依存は既存の RequestShipmentJob のように handle() の引数で受け取ります。
テストでは、そのインスタンスにだけ渡す
UseCase のテストは、Laravel を起動せずに書けます。コンストラクタの引数に、テスト用の実装を渡すだけです。
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
final class ConfirmOrderTest extends TestCase
{
public function test_確定の事実には時計の時刻と確定者が記録される(): void
{
$id = new OrderId('01jaxq3k8w5n2m4p6r7s9t0v1x');
$now = new DateTimeImmutable('2026-11-02 10:00:00', new DateTimeZone('Asia/Tokyo'));
$orders = new InMemoryOrderRepository();
$orders->save(Order::place($id));
$events = new RecordingDomainEventPublisher();
$useCase = new ConfirmOrder(
$orders,
new AlwaysAvailableStockAllocator(),
new ImmediateTransactionManager(),
new FixedClock($now),
new RecordingShipmentRequestQueue(),
$events,
);
$useCase->execute($id, operatorId: 7);
$this->assertEquals([new OrderConfirmed($id, 7, $now)], $events->published);
}
}
テスト用の実装は、どれも数行です。InMemoryOrderRepository は ID をキーにした配列に Order を出し入れし、AlwaysAvailableStockAllocator は常に true を、RecordingShipmentRequestQueue は受け取った ID を配列に残すだけのクラスです(省略)。残りの2つは次のとおりです。
/** トランザクションを張らずに、その場で実行する */
final class ImmediateTransactionManager implements TransactionManager
{
public function run(callable $operation): mixed
{
return $operation();
}
}
/** 渡されたイベントを、配らずに残す */
final class RecordingDomainEventPublisher implements DomainEventPublisher
{
/** @var list<DomainEvent> */
public array $published = [];
public function publish(array $events): void
{
$this->published = [...$this->published, ...$events];
}
}
ImmediateTransactionManager はロールバックもコミット後の実行も再現しません。このテストで確かめているのは、確定の手順が何を記録し、何を依頼するかだけです。トランザクションやロックの振る舞いは、本物の DB を通す Feature Test で確かめます。
なぜそう判断したか
理由は「テストのしやすさ」より「依存が見えること」
DI にする理由として、テストのしやすさがよく挙がります。ただ、Laravel では Facade もかなりテストしやすく作られています。Feature Test なら、Http::fake() や Queue::fake() のほうが手早いことも多く、私も使います。しかも、コンストラクタで Factory を受け取った Gateway にも Http::fake() は効きます(Http::fake の記事で、Gateway をモックにせず HTTP の応答だけを偽物にしたのは、この仕組みを使っています)。Facade も、コンストラクタで受け取る Factory も、コンテナの同じインスタンスを指しているからです。
違いが出るのは、差し替える範囲と、差し替えるものの知り方です。
| Facade | コンストラクタで受け取る | |
|---|---|---|
| 何に触れるか | メソッドの中を読んで知る | コンストラクタの引数で分かる |
| テストでの差し替え | アプリ全体で差し替わる | テストで作ったインスタンスにだけ渡す |
| 差し替え忘れ | 本物に届く | 引数が足りずに、インスタンスを作れない |
| Laravel の起動 | 要る | UseCase のテストなら要らない |
3行目が成り立つのは、Unit Test で UseCase を自分で組み立てる場合だけです。Feature Test でコンテナに組み立てさせれば、DI でも差し替えなかったものは本物に届きます。
私が重く見ているのは、1行目です。テストのしやすさは結果で、目的は、クラスが何に依存しているかを、本体を読まずに知れることです。レビューで影響範囲を追うとき、障害の調査で依存先の停止がどこまで響くかを確かめるとき、その一覧はコンストラクタにあります。
依存が増えたときに目立つのも、同じ理由です。ドメインイベントの記事では、確定の通知・集計・キャッシュの削除を足すたびに ConfirmOrder のコンストラクタが伸び、それをきっかけに副作用をイベントに分けました。Facade で書いていたら、Mail:: と Cache:: の行が増えるだけで、同じきっかけは来なかったはずです。コンストラクタの長さは DI のせいではなく、もともとあった依存が見えるようになっただけです。
app() で取り出す書き方を選ばなかった理由
冒頭の例の app(OrderRepository::class) は、Interface に依存しているので、Facade よりましに見えます。ただ、コンテナから取り出す場所がメソッドの中にある限り、依存が外から見えないことは変わりません。いわゆるサービスロケーターです。コンテナに頼んで依存を探すのは、組み立ての場所(サービスプロバイダ)だけにします。
UseCase に Laravel のコントラクトを渡さない理由
Facade の代わりに、Illuminate\Contracts\Bus\Dispatcher や Illuminate\Contracts\Cache\Repository を UseCase のコンストラクタで受け取る方法もあります。依存は見えるようになりますが、UseCase が知っているのは「ジョブをディスパッチする」「キーを消す」という Laravel の操作のままです。いつ・どう依頼するかの判断が UseCase に残り、業務ルールを Laravel に依存しない場所に置くという方針からも外れます。
Laravel のコントラクトを受け取るのは、Infrastructure の実装です。AfterCommitShipmentRequestQueue が Dispatcher を、AfterCommitDomainEventPublisher が DB の接続を受け取っているように、Laravel の操作は Interface の実装の中に閉じ込めます。
ログだけは例外として扱っています。ログは業務の操作ではなく、どの層にもまたがる記録なので、業務の言葉で包む意味がありません。そのうえで Psr\Log\LoggerInterface は PSR の Interface なので、UseCase のコンストラクタで受け取っても、Laravel への依存にはなりません。冒頭の例の Log::info() を残すなら、LoggerInterface をコンストラクタで受け取ります。同じ PSR でも、キャッシュ(PSR-16)やイベントのディスパッチャ(PSR-14)は業務の操作の手段なので、これまでどおりアプリの Interface の裏に置きます。
Facade を全面的に禁止しない理由
逆に、アプリ全体で Facade を禁止する方法もあります。ただ、サービスプロバイダの Event::listen() や RateLimiter::for()、ミドルウェアの Context::add() は、Laravel に組み込むための記述そのものです。ここを DI に書き換えても、依存しているのが Laravel であることは変わらず、読みにくくなるだけです。
Infrastructure も同じです。キュージョブの記事の DatabasePointLedger は DB::table() を使っていますが、そのクラスは「DB にポイントの記録を書く」ことが役割で、Interface の外に Laravel が漏れていません。私はコントラクトを受け取るほうを選びますが、既存のコードの Facade を急いで置き換えるほどの差はないと考えています。
どこから設計過剰になるか
- 業務ルールのない CRUD や、捨てる前提の PoC:UseCase を置かないなら、Controller で Facade を使えば足ります。依存を並べる場所そのものがありません
- Laravel のコントラクトごとに、自前の Interface で包む:
CacheやDispatcherの薄いラッパーを作っても、名前が変わるだけです。自前の Interface を作るのは、UseCase から業務の言葉で呼びたい操作(「出荷を依頼する」)か、振る舞いを決めたい操作(キャッシュの障害の記事のFailSafeCacheのように、使えないときにどうするか)だけにします - 既存のコードを一度に書き換える:Facade の呼び出しを全部探して置き換えるより、次に触る UseCase から直します。新しく増えないことは、静的解析やアーキテクチャのテストで「UseCase の名前空間から
Illuminate\Support\Facadesを使わない」と決めて CI で止めます。now()・app()・config()のようなヘルパーはグローバル関数なので、名前空間の規則では捕まりません。関数名も禁止の対象に含めます(Pest のアーキテクチャのテストなら、関数名も指定できます) - UseCase のテストを全部 Unit Test で書く:テスト用の実装を渡せるようになっても、テストの中心は HTTP から DB までを通す Feature Test のままです。Unit Test を足すのは、組み合わせの多いルールや、記録する値を細かく確かめたいときです
まとめ
- Facade とヘルパーは、UseCase とドメインでは使わず、依存をアプリの Interface としてコンストラクタに並べる。サービスプロバイダ・入口・Infrastructure・テストでは使ってよい
- DI にする一番の理由は、テストのしやすさより、クラスが何に触れるかが本体を読まずに分かること。コンストラクタが長くなるのは、もともとあった依存が見えるようになっただけ
- 時計とトランザクションも Interface にし、本番の実装は Laravel の時刻と DB 接続に合わせる。リクエストごとに変わる値は注入せず、引数で渡す