CakePHP4でDDDを意識してAPIを作成してみた

# はじめに

GW、自粛であまりにヒマだったため、いつかやろうと思っていた、「ドメイン駆動設計入門 | 成瀬允宣」のソースコードを、 CakePHP4 で作ってみました。
（細かいところは自分で補完してアレンジしています［例外処理とか、、］）

<https://www.amazon.co.jp/dp/B082WXZVPC/ref=cm_sw_r_tw_dp_QSEW1Y4VXAPGZY8EHKGH>

「ドメイン駆動設計 モデリング/実装ガイド | 松岡幸一郎」の影響も受けているため、命名等、上記本とは少し異なっています。

<https://booth.pm/ja/items/1835632>

この記事で紹介するソースコードは、 GitHub にコミットしてあります。

<https://github.com/q23isline/reinventing_the_wheel>

テストはまだ書けていないので、書けた後、元気があれば記事にします。

【2021/11/6 追記】 以下の記事にて、テストについて記事にしました！

<https://qiita.com/rowpure/items/44a1f621a330bf29ee52>

# バージョン

- PHP
    - 8.0.10
- CakePHP
    - 4.3.0

# ディレクトリ構成

```text
reinventing_the_wheel
└src
　├Controller ：プレゼンテーション層［MVCのC］
　├Domain
　│├Models ：ドメインモデル層
　│├Services ：ドメインサービス層
　│└Shared ：例外等の共通ドメイン
　├Infrastructure ：インフラ層
　│├CakePHP ：`find('all')`等をこの中のクラスだけで使う
　│└InMemory ：テスト用（※これから実装予定［2021-05-09時点］）
　├Model ：MVCのM
　└UseCase ：ユースケース（アプリケーションサービス）層
```

# ユースケース図

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586387/d0c7be1e-1b8b-4294-0b7f-4d09ae7f2575.png)

ユーザーの CRUD を実装しています。
権限チェック処理はないので、`admin`というのは不適切ですが…今後実装するということで。

# APIについて

`http://localhost/api/v1/users`の REST full な感じのルーティングとしています。
詳しくは、本リポジトリの`reinventing_the_wheel/docs/api/src/reference/users-v1.html`をブラウザで開くことにより確認できます。

# 実装

すべてのソースコードを載せると30ファイルくらいになってしまうので、「ユーザーを登録する」ユースケースの処理の流れの一部のみを記載します。
こうした方がスマートでない？等のご意見、いただけると嬉しいです！
また、 users テーブル・モデルは作成済、という体で進めます。

## プレゼンテーション層

```php:src/Controller/Api/V1/UsersController.php
declare(strict_types=1);

namespace App\Controller\Api\V1;

// （略）useの設定

class UsersController extends AppController
{
    private CakePHPUserRepository $userRepository;
    private UserService $userService;
    private UserAddUseCase $userAddUseCase;

    public function initialize(): void
    {
        parent::initialize();

        // テスト時のモック用にプロパティのチェック
        $this->userRepository = $this->userRepository ?? new CakePHPUserRepository();
        $this->userService = $this->userService ?? new UserService($this->userRepository);
        $this->userAddUseCase = $this->userAddUseCase ?? new UserAddUseCase($this->userRepository, $this->userService);
    }

    public function add(): void
    {
        $jsonData = $this->request->getData();
        try {
            $command = new UserAddCommand(
                $jsonData['loginId'] ?? null,
                $jsonData['password'] ?? null,
                $jsonData['roleName'] ?? null,
                $jsonData['firstName'] ?? null,
                $jsonData['lastName'] ?? null,
                $jsonData['firstNameKana'] ?? null,
                $jsonData['lastNameKana'] ?? null,
                $jsonData['mailAddress'] ?? null,
                $jsonData['sex'] ?? null,
                $jsonData['birthDay'] ?? null,
                $jsonData['cellPhoneNumber'] ?? null,
            );
            $userId = $this->userAddUseCase->handle($command);
            $result = new UserSavedResult($userId);
            $data = $result->format();

            $this->set($data);
            $this->viewBuilder()->setClassName('Json')->setOption('serialize', ['userId']);
        } catch (ValidateException $e) {
            $data = $e->format();

            $this->response = $this->response->withStatus(400);
            $this->set($data);
            $this->viewBuilder()->setClassName('Json')->setOption('serialize', ['error']);
        }
    }
}
```

## ユースケース層

```php:src/UseCase/Users/UserAddUseCase.php
declare(strict_types=1);

namespace App\UseCase\Users;

// （略）useの設定

class UserAddUseCase
{
    private IUserRepository $userRepository;
    private UserService $userService;

    public function __construct(IUserRepository $userRepository, UserService $userService)
    {
        $this->userRepository = $userRepository;
        $this->userService = $userService;
    }

    public function handle(UserAddCommand $command): UserId
    {
        $birthDay = null;
        if (!empty($command->getBirthDay())) {
            $birthDay = new BirthDay($command->getBirthDay());
        }

        $cellPhoneNumber = null;
        if (!empty($command->getCellPhoneNumber())) {
            $cellPhoneNumber = new CellPhoneNumber($command->getCellPhoneNumber());
        }

        $data = User::create(
            $this->userRepository->assignId(),
            new LoginId($command->getLoginId()),
            new Password($command->getPassword()),
            new RoleName($command->getRoleName()),
            new FirstName($command->getFirstName()),
            new LastName($command->getLastName()),
            new FirstNameKana($command->getFirstNameKana()),
            new LastNameKana($command->getLastNameKana()),
            new MailAddress($command->getMailAddress()),
            new Sex($command->getSex()),
            $birthDay,
            $cellPhoneNumber,
        );

        if ($this->userService->isExists($data)) {
            throw new ValidateException([new ExceptionItem('loginId', 'ログインIDは既に存在しています。')]);
        }

        return $this->userRepository->save($data);
    }
}
```

コマンドクラス内は、リクエストパラメータの getter を定義しています。

## ドメインサービス層

```php:src/Domain/Services/UserService.php
declare(strict_types=1);

namespace App\Domain\Services;

// （略）useの設定

final class UserService
{
    private IUserRepository $userRepository;

    public function __construct(IUserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function isExists(User $user): bool
    {
        $duplicatedUser = $this->userRepository->findByLoginId($user->getLoginId());

        if (is_null($duplicatedUser) || $duplicatedUser->isMyself($user)) {
            return false;
        }

        return true;
    }
}
```

ユーザーの存在チェックを行います。

## ドメイン層

```php:src/Domain/Models/User/User.php
declare(strict_types=1);

namespace App\Domain\Models\User;

// （略）useの設定

final class User
{
    private UserId $id;
    private LoginId $loginId;
    private Password $password;
    private RoleName $roleName;
    private FirstName $firstName;
    private LastName $lastName;
    private FirstNameKana $firstNameKana;
    private LastNameKana $lastNameKana;
    private MailAddress $mailAddress;
    private Sex $sex;
    private ?BirthDay $birthDay = null;
    private ?CellPhoneNumber $cellPhoneNumber = null;

    private function __construct(
        UserId $id,
        LoginId $loginId,
        Password $password,
        RoleName $roleName,
        FirstName $firstName,
        LastName $lastName,
        FirstNameKana $firstNameKana,
        LastNameKana $lastNameKana,
        MailAddress $mailAddress,
        Sex $sex,
        ?BirthDay $birthDay,
        ?CellPhoneNumber $cellPhoneNumber
    ) {
        $this->id = $id;
        $this->loginId = $loginId;
        $this->password = $password;
        $this->roleName = $roleName;
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->firstNameKana = $firstNameKana;
        $this->lastNameKana = $lastNameKana;
        $this->mailAddress = $mailAddress;
        $this->sex = $sex;
        $this->birthDay = $birthDay;
        $this->cellPhoneNumber = $cellPhoneNumber;
    }

    public static function create(
        UserId $id,
        LoginId $loginId,
        Password $password,
        RoleName $roleName,
        FirstName $firstName,
        LastName $lastName,
        FirstNameKana $firstNameKana,
        LastNameKana $lastNameKana,
        MailAddress $mailAddress,
        Sex $sex,
        ?BirthDay $birthDay,
        ?CellPhoneNumber $cellPhoneNumber
    ): self {
        return new self(
            $id,
            $loginId,
            $password,
            $roleName,
            $firstName,
            $lastName,
            $firstNameKana,
            $lastNameKana,
            $mailAddress,
            $sex,
            $birthDay,
            $cellPhoneNumber,
        );
    }

    public function isMyself(User $other): bool
    {
        if ($this === $other) {
            // 同じクラスの同じインスタンスであれば true
            return true;
        }

        return $this->id->getValue() === $other->getId()->getValue();
    }

    public function getId(): UserId
    {
        return $this->id;
    }

    // （略）各 getter を定義
}
```

直接 User オブジェクトを生成できないよう、コンストラクタは private としています。
（とはいっても、`create`メソッドでもらった引数をそのまま使ってオブジェクト生成しているので現状意味ないですが・・・）
PHP は読み取り専用プロパティのサポートがないため、プロパティの定義は private のみとしています。
User エンティティと同階層に Type ディレクトリを配置し、その配下で User の値オブジェクト（LoginId等）をまとめています。
あわせて、エンティティと同階層にリポジトリのインターフェイス`IUserRepository`を定義しています。

## インフラ層

```php:src/Infrastructure/CakePHP/Users/CakePHPUserRepository.php
declare(strict_types=1);

namespace App\Infrastructure\CakePHP\Users;

// （略）useの設定

final class CakePHPUserRepository implements IUserRepository
{
    public function assignId(): UserId
    {
        return new UserId(Text::uuid());
    }

    public function findByLoginId(LoginId $loginId): ?User
    {
        $model = TableRegistry::getTableLocator()->get('Users');
        $records = $model->find()->where(['username' => $loginId->getValue()])->toArray();

        if (empty($records)) {
            return null;
        }

        // パスワード等 Entity の hidden 項目を toArray() で返すようにする
        $record = $records[0]->setHidden([]);

        return $this->buildEntity($record->toArray());
    }

    public function save(User $user): UserId
    {
        $model = TableRegistry::getTableLocator()->get('Users');

        $saveData = [
            'Users' => [
                'username' => $user->getLoginId()->getValue(),
                'password' => $user->getPassword()->getValue(),
                'role' => $user->getRoleName()->getValue(),
                'first_name' => $user->getFirstName()->getValue(),
                'last_name' => $user->getLastName()->getValue(),
                'first_name_kana' => $user->getFirstNameKana()->getValue(),
                'last_name_kana' => $user->getLastNameKana()->getValue(),
                'mail_address' => $user->getMailAddress()->getValue(),
                'sex' => $user->getSex()->getValue(),
                'birth_day' => is_null($user->getBirthDay()) ? null : $user->getBirthDay()->getValue(),
                'cell_phone_number' =>
                    is_null($user->getCellPhoneNumber()) ? null : $user->getCellPhoneNumber()->getValue(),
            ],
        ];

        $entity = $model->newEmptyEntity();
        $entity = $model->patchEntity($entity, $saveData);
        // $saveData に id を設定しても patchEntity() 時に id が消え去るため、明示的に設定
        $entity->id = $user->getId()->getValue();
        $saved = $model->saveOrFail($entity);

        return new UserId($saved->id);
    }

    private function buildEntity(array $record): User
    {
        $birthDay = null;
        if (!empty($record['birth_day'])) {
            $birthDay = new BirthDay($record['birth_day']->format('Y-m-d'));
        }

        $cellPhoneNumber = null;
        if (!empty($record['cell_phone_number'])) {
            $cellPhoneNumber = new CellPhoneNumber($record['cell_phone_number']);
        }

        return User::create(
            new UserId($record['id']),
            new LoginId($record['username']),
            new Password($record['password']),
            new RoleName($record['role']),
            new FirstName($record['first_name']),
            new LastName($record['last_name']),
            new FirstNameKana($record['first_name_kana']),
            new LastNameKana($record['last_name_kana']),
            new MailAddress($record['mail_address']),
            new Sex($record['sex']),
            $birthDay,
            $cellPhoneNumber,
        );
    }
}
```

CakePHPのメソッドをバンバン使っています。

# おわりに

シンプルなユーザー CRUD なのに、GW が半分以上消えてしまいました。
言語間のギャップの調査や、こういう時はどうすれば？ということに悩む時間が多かったです。
おかしなところ等、アドバイスいただけると助かります！
この記事が、 CakePHP で DDD するエンジニアに少しでも役立てば嬉しいです。

本記事のコードレビューしていただいた会社の先輩に感謝します。