エンティティの属性を公開せずにリポジトリで永続化する

前置き

こんにちは。うしおです。
前回の記事の続きになります。
今回の話は、エンティティとリポジトリの実装についてです。
ソースコードこちらに置いています。

やりたいこと

下記のようにユースケースを書けることを目指します。

<?php
// 注文受付のユースケース
final class Interactor
{
    public function __construct(
        private readonly OrderRepository $orderRepository,
    )
    {

    }

    public function execute(string $id): void
    {
        $order = $this->orderRepository->findById($id);
        $order->accept();
        $this->orderRepository->save($order);
    }
}

問題点

リポジトリ側の処理を雑に書いてみます。

<?php
final class OrderRepository
{
    public function save(Order $order): void
    {
        // Eloquentを使う想定
        \App\Models\Order::findOrFail($order->id)
            ->fill([
                'id' => $order->id,
                'accepted' => $order->accepted,
            ])->saveOrFail();
    }
}

これでユースケースの様に書けそうです。
しかし、困った問題が出てしまいました。
Orderクラスでidやacceptedなどのプロパティを公開しなければいけません。
エンティティがプロパティやゲッターで属性を公開すると、ドメインロジックがエンティティ外に流出しやすくなります。
下記のようなエンティティを永続化するためにはどうすれば良いでしょうか。

<?php
final class Order
{
    private string $id; // ゲッターも用意しない
    private bool $accepted; // ゲッターも用意しない

    public function __construct(...)
    {
        // ...
    }

    // エンティティ操作を行うメソッドのみがpublic
    public function accept(): void
    {
        $this->accepted = true;
    }
}

改良版のリポジトリ

Closure::bindを使います。

<?php
final class OrderRepository
{
    public function save(Order $order): void
    {
        Closure::bind(function() use ($order) {
            \App\Models\Order::findOrFail($order->id)
                ->fill([
                    'id' => $order->id,
                    'accepted' => $order->accepted,
                ])->saveOrFail();
        }, null, Order::class)();
    }
}

これで属性を公開していないエンティティを永続化できました。
エンティティのカプセル化をすり抜けているのでハック寄りなコードですが、リポジトリだけにエンティティの属性を公開する正攻法は今のところPHPにはありません。
とはいえ、アプリケーションのリポジトリ実装でこのコードが頻出するのは好ましくありませんね。
処理を切り出しましょう。

<?php
class EntityRepository
{
    protected function __construct(
        protected readonly string $entity,
    )
    {
        if ($entity === '') {
            throw new InvalidArgumentException('entity name is required');
        }
    }

    final protected function getAttributes(mixed $entity): array
    {
        return Closure::bind(function() use ($entity) {
            return get_object_vars($entity);
        }, null, $this->entity)();
    }
}

こうしておけば、リポジトリ実装は下記でいけそうです。

<?php
final class OrderRepository extends EntityRepository
{
    public function __construct()
    {
        parent::__construct(Order::class);
    }

    public function save(Order $order): void
    {
        $attr = $this->getAttributes($order);
        \App\Models\Order::findOrFail($attr['id'])
                ->fill($attr)
                ->saveOrFail();
    }
}

これなら良いでしょう。
実際のソースコードでは、今回の内容をライブラリ化するとともに、エンティティの生成部分もファクトリとリポジトリでのみ行うようにしています。
生成と永続化を隠蔽し、安全なエンティティクラスが出来上がりました。

最後に

今回の内容の他にもドメインイベント実装やLaravelの導入などを行っています。
Livewireも利用しているので、次回はその内容について書きたいですね。
それではまた次回に。