🗒

[PHP8.2]ファーストクラスコレクションのサンプル

2024/01/02に公開

はじめに

現在、DDD(ドメイン駆動設計)を採用したプロジェクトにジョインしています。

最近環境をPHP8.2にアップグレードしました。
中でもクラスにreadonly修飾子が指定できるようになってイミュータブル化がしやすくなったので、PHP8.2に合わせた、イミュータブルなファーストクラスコレクションの実装をまとめたいと思います。

対象言語:PHP8.2(Laravelなどに採用されているCollectionの利用を想定)

ファーストクラスコレクションとは

ファーストクラスコレクションは、「ThoughtWorksアンソロジー」で登場するデザインパターンです。

あるエンティティの集合を扱いたい場合、配列や素のCollectionで管理すると、利用箇所ごとにforeachやfilterで絞り込む必要があったり、処理を行ったりと、ビジネスロジックが散らばってしまいます。
また、PHPの型指定は(マシになっていますが)複雑なarrayやCollectionの中身の型をIDEが判定してくれないといった懸念点もあります。
(判定はしてくれても補完が不十分だったり、内容を確認するのが面倒だったりします)

そこで、ファーストクラスコレクションを使ってカプセル化することで、ビジネスロジック処理を1ヶ所にまとめて修正時の影響範囲を制限できたり、型定義が明確になるといったメリットがあります

実装イメージ

月並みですがeコマースプラットフォームを作るとして、カート内の商品をまとめるファーストクラスコレクションのサンプルを示します。
(説明に直接関係ないところは簡略化しています。)

方針

  • ファーストクラスコレクション内部にはメンバ変数としてCollectionを持たせる
  • ビジネスロジックはファーストクラスコレクション内に閉じ込める
  • イミュータブルな実装とする
    • 内部の要素を取り出す際はcloneして、元のデータに影響しない形にして取得する
    • 「ThoughtWorksアンソロジー」のファーストクラスコレクションでは定義されていませんが、安全性のために採用しています。

サンプルコード

カート内の商品

簡略化のため、priceやquantitiyはバリューオブジェクトではなくプリミティブ型を使用しています。

readonly class CartItem
{
    public function __construct(
        public CartItemId $id,
        public int        $price,
        public int        $quantity,
    ) {
    }

    /**
     * 小計を計算する
     */
    public function subtotal(): int
    {
        return $this->price * $this->quantity;
    }
}

ファーストクラスコレクション

クラス名はCartItemListのような形式を採用しています。
(CartItemsだと、ただの集合でビジネスロジックを持たないように感じてしまうため)

CartItemListのドメイン知識(ルール・制約)は下記です。

  • 同じIDの商品は重複しない
  • カート内の商品の合計金額を算出できる
readonly class CartItemList
{
    /** @var Collection<int, CartItem> */
    private Collection $cartItems;

    /**
     * @param CartItem[] $cartItems
     */
    public function __construct(
        array $cartItems
    ) {
        // IDの重複チェック
        if ($this->hasDuplicateId($cartItems)) {
            throw new DomainException('IDが重複しています。');
        }

        $this->cartItems = collect($cartItems);
    }

    /**
     * CartItemIdで絞り込む(CartItemのコピーを返す)
     */
    public function findById(CartItemId $id): ?CartItem
    {
        $findItem = $this->cartItems->first(
            fn (CartItem $item) => $item->id->equals($id)
        );

        return $findItem ? clone $findItem : null;
    }

    /**
     * 全てのデータを配列として取得する
     * データはコピーを返す
     * @return CartItem[]
     */
    public function toArray(): array
    {
        return $this->cartItems->map(
            fn (CartItem $item) => clone $item
        )->all();
    }

    /**
     * カート内全商品の合計金額を計算する
     */
    public function totalPrice(): int
    {
        return $this->cartItems->sum(
            fn (CartItem $item) => $item->subtotal()
        );
    }

    /**
     * カートに商品を追加する
     * ただし、同じIDが存在した場合は上書きする
     */
    public function add(CartItem $cartItem): CartItemList
    {
        // IDが重複していたらコレクションから削除
        $filteredCartItems = $this->cartItems->filter(
            fn (CartItem $item) => !$item->id->equals($cartItem->id)
        )->all();

        return new CartItemList([...$filteredCartItems, $cartItem]);
    }

    /**
     * CartItemIdの重複を確認する
     * @param CartItem[] $cartItemArray
     */
    private function hasDuplicateId(array $cartItemArray): bool
    {
        $cartItems = collect($cartItemArray)->unique(
            fn (CartItem $item) => $item->id->value
        );

        // IDの重複があるかどうかを判定
        return $cartItems->count() !== count($cartItemArray);
    }
}

コードの補足

イミュータブルな設計

データを登録する際は、メンバ変数に直接追加するのではなく、データを追加したファーストクラスコレクションを新規作成して返しています。

    public function add(CartItem $cartItem): CartItemList
    {
        ...
        return new CartItemList([...$filteredCartItems, $cartItem]);
    }

また、下記のようにコレクション内のデータを取得する際にはcloneして、元のインスタンスに影響がないようにしています。
PHPのcloneだとシャローコピーですが、CartItem自体もイミュータブルなオブジェクトとして実装するため、特に影響はないと思われます。

    /**
     * CartItemIdで絞り込む(CartItemのコピーを返す)
     */
    public function findById(CartItemId $id): ?CartItem
    {
        $findItem = $this->cartItems->first(
            fn (CartItem $item) => $item->id->equals($id)
        );

        return $findItem ? clone $findItem : null;
    }

    /**
     * 全てのデータを配列として取得する
     * データはコピーを返す
     * @return CartItem[]
     */
    public function toArray(): array
    {
        return $this->cartItems->map(
            fn (CartItem $item) => clone $item
        )->all();
    }

まとめ

以上、最近使っているファーストクラスコレクションのサンプルをまとめました。
疑問点や懸念点があればコメントいただければと思います。

Discussion