Laravel / PHP ·

Facadeを使わずDIで書くと何が変わるか — 見える依存と使ってよい場所

  • Laravel
  • 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 接続に合わせる。リクエストごとに変わる値は注入せず、引数で渡す

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。