アーキテクチャ ·

CQRSを小さく始める — 検索はRepositoryでなくクエリクラスに

  • CQRS
  • Repository
  • 設計

この記事で扱う問題

Repository Interface の記事では、OrderRepository を「注文を1件取り出す」「保存する」という操作だけで定義しました。ところが、画面や API が増えるにつれて、例えば次のようにメソッドが足されていきます。

interface OrderRepository
{
    public function findOrFail(OrderId $id): Order;

    public function findForUpdate(OrderId $id): Order;

    public function save(Order $order): void;

    // ここから下は、画面や API が増えるたびに足されたもの

    /** @return list<Order> 利用者の注文の一覧画面 */
    public function findByUserId(int $userId, int $page): array;

    /** @return list<Order> 確定されないまま残っている注文を探すバッチ */
    public function findPendingOlderThan(DateTimeImmutable $before): array;

    /** @return list<array{id: string, status: string, customer_name: string, total_amount: int}> 管理画面の検索 */
    public function searchForAdmin(?OrderStatus $status, ?string $customerName, int $page): array;

    /** 管理画面のダッシュボード */
    public function countByStatus(OrderStatus $status): int;
}

1つずつは自然な追加です。ただ、例えば次のことが起こり得ます。

  • 一覧のために、集約を丸ごと組み立てる:findByUserId() が Order を返すので、一覧に出すのは ID と状態と日時だけなのに、明細まで読み込み、reconstitute() で不変条件を確かめ、20件分の集約を作ります
  • 表示の都合が、集約の形を変えようとする:一覧に顧客名や商品名を出したくなると、Order に Customer や Product を持たせたくなります。集約の境界を、画面の都合で広げることになります
  • 戻り値の型が、メソッドごとにばらばらになる:searchForAdmin() は集約では足りないので配列を返し、countByStatus() は整数を返します。Repository が「集約を保存する」ためのものなのか、「画面に値を渡す」ためのものなのか、読んでも分からなくなります
  • Repository の変更理由が増える:管理画面の絞り込みを1つ足すだけで、ドメインの層の Interface と、その Eloquent の実装と、テスト用の Fake Repository の3か所に手が入ります。Fake の検索条件は本物の SQL と別に書くので、条件の解釈がずれても、テストは通ります

業務ルールを守るためのクラスに、表示の要件が積み重なっていく。これが、この記事で扱う問題です。

判断

書く側と読む側を、クラスの単位で分ける

私は、CQRS(Command Query Responsibility Segregation:更新と参照の責務を分けること)を、まずクラスの単位で取り入れます。DB もテーブルも分けません。同じ DB の同じテーブルを、書く側と読む側が別のクラスから扱うだけです。

書く側(Command) 読む側(Query)
使う場面 状態を変える操作(確定・キャンセル・明細の追加) 画面や API に値を返す
入口 UseCase クエリクラス(Controller から直接呼ぶ)
DB を扱うクラス Repository(findOrFail()・findForUpdate()・save() だけ) 用途ごとのクエリクラス
返すもの 集約(Order) 画面に必要な列だけを持つ DTO
守るもの 業務ルールと不変条件 見せてよい範囲(本人の注文だけ、など)

Repository は、状態を変えるために集約を取り出し、保存するためのものに戻します。冒頭の Interface でいえば、findOrFail()・findForUpdate()・save() だけを残し、表示のための findByUserId()・searchForAdmin()・countByStatus() は用途ごとのクエリクラスに移します。

findPendingOlderThan() は、見つけた注文を期限切れとして取り消すバッチのためのものなので、扱いが違います。読んだ結果で状態を変えるからです。対象の ID の一覧だけをクエリで探し、1件ずつ別のトランザクションで findForUpdate() で取り直して、cancel() を呼んで保存します。探してから取り直すまでの間に利用者が確定していれば、cancel() が確定済みの注文を取り消さずに止めます。止めるときは例外(OrderCannotBeCancelled)を投げるので、バッチはそれを捕まえてその注文を飛ばし、次の注文へ進みます。

注文の一覧は、ページネーションの記事の OrderListQuery が、すでにこの形になっています。ここでは、注文の詳細画面を例にします。

クエリクラスは、画面に必要な形の DTO を返す

詳細画面には、注文の状態と合計、明細ごとの商品名・数量・単価を出すとします。まず、返す DTO です。値の入れ物なので、Value Object にはせず、プリミティブ型のまま持ちます。

final readonly class OrderDetailLine
{
    public function __construct(
        public string $sku,
        public ?string $productName,   // 商品が削除されていれば null
        public int $quantity,
        public int $unitPrice,         // 注文した時点の単価(円)
    ) {
    }
}

final readonly class OrderDetailView
{
    /** @param list<OrderDetailLine> $lines */
    public function __construct(
        public string $id,
        public string $status,
        public int $totalAmount,
        public string $createdAt,
        public ?string $confirmedAt,
        public array $lines,
    ) {
    }
}

クエリクラスは、必要な列だけを Query Builder で読み、商品名は商品のテーブルを結合して取ります。テーブルと列(orders.total_amount・order_lines.unit_price・products.name など)は、この記事の例として仮定したものです。products.sku には一意制約がある前提です(一意でないと、結合で明細が重複します)。total_amount は、注文を保存するときに OrderMapper が Order::total() の値を書いている前提です。

use Illuminate\Database\ConnectionInterface;

final class OrderDetailQuery
{
    public function __construct(private ConnectionInterface $db)
    {
    }

    /** 利用者本人の注文だけを返す。存在しない注文と、ほかの利用者の注文は、区別せずに null を返す */
    public function find(OrderId $id, int $viewerId): ?OrderDetailView
    {
        $order = $this->db->table('orders')
            ->where('id', $id->value)
            ->where('user_id', $viewerId)
            ->first(['id', 'status', 'total_amount', 'created_at', 'confirmed_at']);

        if ($order === null) {
            return null;
        }

        $lines = $this->db->table('order_lines')
            ->leftJoin('products', 'products.sku', '=', 'order_lines.sku')
            ->where('order_lines.order_id', $order->id)
            ->orderBy('order_lines.id')
            ->get(['order_lines.sku', 'products.name', 'order_lines.quantity', 'order_lines.unit_price'])
            ->map(fn (object $row): OrderDetailLine => new OrderDetailLine(
                $row->sku,
                $row->name,
                (int) $row->quantity,
                (int) $row->unit_price,
            ))
            ->all();

        return new OrderDetailView(
            $order->id,
            $order->status,
            (int) $order->total_amount,
            $order->created_at,
            $order->confirmed_at,
            $lines,
        );
    }
}

合計は、明細から計算し直さず、書く側が保存した値を読みます。単価×数量の合計なら読み取り側でも計算できますが、送料や値引きのルールが入ったとき、読み取り側に同じ計算が残っていると、画面の合計と保存した合計がずれます。金額のルールは書く側にだけ置き、読む側は結果を読む、と決めておきます。

商品名は、注文した時点の名前ではなく、いまの名前です。「注文した時点の商品名を出す」ことが要件なら、それは表示の問題ではなく、単価と同じく注文の内容として書く側で order_lines に写しておくべき値です。読み取り側のクラスで、どちらの名前を出すかを工夫して決めることではありません。

Controller は、UseCase を挟まずにクエリクラスを呼びます。ルートパラメータの ID の形式が違う場合も、ほかの利用者の注文の場合も、404 にします。

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class ShowOrderController
{
    public function __construct(private OrderDetailQuery $query)
    {
    }

    public function __invoke(Request $request, string $orderId): JsonResponse
    {
        try {
            $id = new OrderId($orderId);
        } catch (InvalidOrderId) {
            abort(404);
        }

        $view = $this->query->find($id, $request->user()->id);

        if ($view === null) {
            abort(404);   // ほかの利用者の注文があることも、知らせない
        }

        return response()->json($view);   // 公開する項目の形は、API のレスポンスとして別に決める(省略)
    }
}

クエリクラスは、用途ごとに1つ作る

クエリクラスの名前は、テーブルではなく用途から付けます。OrderListQuery(利用者の注文の一覧)、OrderDetailQuery(注文の詳細)、AdminOrderSearchQuery(管理画面の検索)のように、1つのクラスが1つの画面や API に対応し、公開するメソッドも基本は1つです。

OrderQueryService のような1つのクラスに、一覧・詳細・検索・件数のメソッドを並べることはしません。それをすると、冒頭の Repository の問題を、名前を変えて作り直すだけになります。

用途ごとに分けると、似た条件があちこちに書かれます。並び順や列の選び方の重複は、画面ごとに別の理由で変わるものなので、無理に共通にしません。一方で、見せてよい範囲の条件(本人の注文だけ、削除済みを出さない、など)は、どのクエリにも漏れなく必要です。共通の部品にするかどうかより、「他人の注文は見られない」を、クエリクラスごとに Feature Test で確かめることを優先します。

状態を変える判断に、クエリクラスを使わない

境界は、読んだ結果を使って状態を変えるかどうかで引きます。

  • 画面に出すだけなら、クエリクラス
  • 読んだ値で業務のルールを確かめ、状態を変えるなら、Repository で集約を取り出すか、ルールに必要な状態だけを条件付きの UPDATE で扱う

例えば、「未払いの注文は1人3件まで」を確かめるために、UseCase から一覧のクエリを呼んで件数を数える、ということはしません。クエリクラスはロックを取らずに読むので、同時に来た2つの注文がどちらも「2件」を読みます。このルールは、集約の記事で書いたように、件数そのものを条件付きの UPDATE で増やして守ります。クエリクラスは、間違っていても画面の表示がずれるだけで済む読み取りに限ります。

次の段階:読み取りの接続を、クエリクラス単位で選べる

クラスを分けておくと、その先の選択肢が増えます。例えば、重い検索だけを DB のリードレプリカに逃がしたくなったら、クエリクラスに渡す接続を変えるだけで済みます(接続の名前 replica は、config/database.php に定義してある前提です)。

use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\ServiceProvider;

final class QueryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // 管理画面の検索だけ、レプリカから読む。数秒古い結果でよいことを、画面の要件として確かめてから
        $this->app->when(AdminOrderSearchQuery::class)
            ->needs(ConnectionInterface::class)
            ->give(fn ($app): ConnectionInterface => $app['db']->connection('replica'));
    }
}

レプリカは書き込みから遅れます。利用者が注文を確定した直後に開く詳細画面をレプリカから読むと、確定前の状態が見えます。そのため、利用者が自分の操作の結果を見る画面(OrderDetailQuery)はプライマリのまま残し、担当者が検索する画面のように、少し古くても困らないものだけを移します。

Laravel の接続設定の read / write で読み書きを分ける方法もあります。ただ、既定ではその接続の SELECT が読み取り側に振られる(トランザクション中や、sticky を有効にして同じリクエストで書き込んだ後を除く)ので、どの読み取りなら遅れを許容できるかを、クエリごとに外していく書き方になります。私は、遅れを許容できる読み取りを、クラス単位で選んで移すほうが追いやすいと考えています。

なぜそう判断したか

Repository に検索メソッドを足し続けない理由

Repository の戻り値は集約です。集約は「コミットの瞬間に一緒に正しくなければならないルール」を守る単位で、画面に出す形ではありません。一覧に顧客名を出したい、件数を出したい、という要求は、集約が守るルールとは関係がありません。その要求を Repository で受けると、戻り値を配列にするか、集約を画面に合わせて広げるかのどちらかになり、どちらも Repository の役割をぼやけさせます。

書く側の Repository が、findOrFail()・findForUpdate()・save() だけなら、Fake Repository も ID で引いて保存するだけで済みます。UseCase のテストで差し替えるものが単純になるのは、業務ルールのテストを DB なしで書くという Repository の本来の利点を守ることでもあります。

汎用の検索メソッド(findBy($criteria))にしない理由

検索メソッドが増えるのを嫌って、findBy(array $criteria) や Specification パターンのような汎用の入口を作る方法もあります。メソッドの数は増えませんが、どんな条件の組み合わせが使われるかが、呼び出し側を全部読まないと分からなくなります。インデックスが効く組み合わせかどうかも、Repository の側からは判断できません。Fake Repository は、その汎用の条件を自分で解釈し直す必要があります。

用途ごとのクエリクラスなら、そのクラスが発行する SQL は1通りか数通りに決まります。実行計画を確かめる対象も、クラスごとに分かります。

クエリクラスに Interface を置かない理由

Repository には Interface を置いて Fake に差し替えますが、クエリクラスには置いていません。クエリクラスの仕事は「この画面のために、どの列をどう結合して読むか」で、その中身は SQL そのものです。Fake に差し替えると、確かめたい SQL を通らないテストになります。

クエリクラスは、本物の DB を使う Feature Test で、返る値と見せてよい範囲を確かめます。テストが遅くなるのはコストですが、読み取りのバグは、たいてい結合の条件や絞り込みの漏れにあり、そこは本物の DB を通さないと見つかりません。

クエリクラスの中で、Eloquent を使ってもよい

この記事では Query Builder を使っていますが、境界はクラスの単位なので、クエリクラスの中で Eloquent を使っても構いません。ただし、Eloquent のモデルをクエリクラスの外に返さないことは守ります。モデルを返すと、呼び出し側で遅延読み込みが走って N+1 になったり、表示のために受け取ったモデルが save() されたりする経路が残ります。外に出すのは DTO だけにします。

読み取り専用のテーブルから始めない理由

CQRS というと、書く側と読む側で DB を分け、読む側のテーブルをイベントで更新する構成を思い浮かべることが多いと思います。読む側のテーブルを持つと、画面ごとに最適な形でデータを持てますが、書く側との間に遅れが生まれ、更新の取りこぼしや、作り直しの手順も設計に入ってきます。

ドメインイベントの記事の「日ごとの売上」は、この読む側のテーブルの小さな例です。確定済みの注文を毎回集計すると重いので、別の行に持ち、イベントと夜間の作り直しで追いつかせています。逆に言えば、そこまでの仕組みが要るのは、同じテーブルを読むだけでは間に合わない読み取りに限られます。

私は、まずクラスを分け、同じテーブルからの読み取りで、インデックスを見直しても間に合わないと分かった読み取りだけを、読む側のテーブルに移す順で考えます。読む側のテーブルとイベントで組む構成は、私自身まだ深めている途中の領域ですが、クラスの単位で分けておけば、その移行は1つのクエリクラスの中身を差し替える作業で済みます。

どこから設計過剰になるか

  • 業務ルールのない CRUD:管理画面のマスタの編集のように、書く側にも守るルールがほとんどない機能では、書く側と読む側を分ける意味がありません。Eloquent のモデルと API Resource で足ります
  • クエリクラスごとに Interface と Fake を作る:上で書いたとおり、確かめたい SQL を通らなくなります。差し替えたい理由がはっきりするまでは、具体的なクラスのままにします
  • 書く側にもコマンドバスを入れる:CQRS を取り入れるからといって、Command のクラスとハンドラーとバスを用意する必要はありません。UseCase のクラスが1つの操作を受け持っていれば、書く側の分離としては足ります
  • 最初からレプリカや読む側のテーブルを用意する:遅れの扱いや作り直しの手順が、必要になる前から設計の対象になります。分けるのは、同じテーブルからの読み取りでは間に合わないと分かってからにします
  • 似たクエリを無理に1つにまとめる:返す列が少し違うだけのクエリを、引数のフラグで出し分けるクラスにすると、片方の画面の変更がもう片方を壊します。重複のほうが安く済むことが多いです

まとめ

  • Repository は、状態を変えるために集約を取り出して保存するものに限り、画面や API のための読み取りは用途ごとのクエリクラスから DTO で返す
  • 境界は「読んだ結果で状態を変えるか」で引き、金額のような業務のルールは書く側にだけ置いて、読む側は保存された結果を読む
  • DB もテーブルも分けずにクラスの単位から始め、レプリカや読む側のテーブルは、間に合わないと分かった読み取りにだけ使う

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