🧭

【DDD】カーソルページネーションを壊さない:検索をSpecificationに寄せてみる

に公開

こんにちは、カンリー福利厚生で開発をしているaoyyです!
今回は福利厚生で行ったSpecificationパターンの採用についてお話しできればと思います。

Specificationパターンとは

検索・判定条件をオブジェクト化し、組み合わせ可能(AND/OR/NOT)にする設計パターンのことです。メリットとしては同じ条件の再利用が可能になること、関心の分離を適切に行うことができることなどがあります。

背景

カンリー福利厚生ではバックエンドでDDDを採用しています。
新機能の開発にあたり、フロント側の要件としてエンドレススクロールでの実装を求められる箇所がありました。パフォーマンスを求められる画面だったため、エンドレススクロールと相性がいいカーソルページネーションを使用したいと考えていました。
ただ、DDDでは検索/フィルタリング条件を基本的にユースケース層行うためカーソルが壊れてしまい、既存で採用している設計パターンではデータの不整合や重複が発生する状態でした。

なぜ壊れるのか

  • ユースケース層で再フィルタすると、DBでの取得順序と返却順序がズレる
  • カーソルは「前ページ末尾の並び順」を指すため、順序がズレると次ページで重複 or 欠落が発生
  • 解決策は、取得時点で最終条件までDBに押し込むこと(= リポジトリ層でクエリを組み立てること)

Specificationパターンの採用

福利厚生ではリポジトリ層でDBからデータ取得を行っていますが、リポジトリにそのまま検索条件を寄せるとビジネスロジックが漏れてしまうことになります。そのため、関心の分離は適切に行いつつカーソルを壊さない設計が可能なSpecificationパターンを採用することにしました。今回はクエリをSpecクラスに記載せず、下記のようにあくまで条件を知っているクラスとして実装しました。

Spec

<?php
// Domain/Item/Specifications/ItemSpec.php
declare(strict_types=1);

namespace Domain\Item\Specifications;

interface ItemSpec
{
    /** @return array<string, mixed> 意図を返す */
    public function toFilter(): array;
}
<?php
// Domain/Item/Specifications/ActiveItemSpec.php
declare(strict_types=1);

namespace Domain\Item\Specifications;

final class ActiveItemSpec implements ItemSpec
{
    /** @return array{requireActive: bool} */
    public function toFilter(): array
    {
        return ['onlyActive' => true];
    }
}

リポジトリ

  <?php

  declare(strict_types=1);

  namespace App\Repositories;

  use App\Models\Item;
  use App\Domain\Specifications\ActiveItemSpec;
  use Illuminate\Contracts\Pagination\CursorPaginator;

  class ItemRepository implements ItemRepositoryInterface
  {
      public function search(
          bool $onlyActive,
          int $limit = 20,
          ?string $cursor = null
      ): CursorPaginator {
          // パラメータからSpecificationを組み立て
          $specs = [];

          if ($onlyActive) {
              $specs[] = new ActiveItemSpec();
          }

          // Specificationを配列にマージ
          $filters = [];
          foreach ($specs as $spec) {
              $filters = array_replace_recursive(
                  $filters,
                  $spec->toFilter()
              );
          }

          // フィルタ配列を解釈してクエリを構築
          $query = Item::query();

          // Specificationの条件を適用
          if ($filters['onlyActive'] ?? false) {
              $query->where('status', 'active');
          }

          // カーソルページネーション
          return $query->cursorPaginate(
              perPage: $limit,
              cursor: $cursor
          );
      }
  }

UseCase

  <?php

  declare(strict_types=1);

  namespace App\UseCases;

  use App\Repositories\ItemRepositoryInterface;
  use Illuminate\Contracts\Pagination\CursorPaginator;

  class SearchItemsAction
  {
      public function __construct(
          private readonly ItemRepositoryInterface $repository
      ) {
      }

      public function execute(
          bool $onlyActive,
          int $limit = 20,
          ?string $cursor = null
      ): CursorPaginator {
          return $this->repository->search(
              onlyActive: $onlyActive,
              limit: $limit,
              cursor: $cursor
          );
      }
  }

注意点

Specificationパターンで実装する際、Specクラスの共通化などを適切に行わないとクラスがドメインごとに増えてしまい、テストクラスも併せて増えるためファイル数が多くなりすぎます。恒常条件はベースクエリに寄せ、Specは機能の意図だけに限定し、似た条件はクラスを増やさず引数でパラメータ化するなどの対策が必要になります。

まとめ

DDDでは検索条件をユースケース層で扱う設計が一般的ですが、後段フィルタでDBの並びと返却順がずれると重複・欠落が起き、カーソルページネーションと相性が悪くなります。
DDDとエンドレススクロールを両立させるにはフィルタ意図をSpecificationで表現し、取得時点でDBに寄せ切るのが有効かなと思いますのでぜひSpecificationパターンの採用を検討してみてください!

カンリーテックブログ

Discussion