はじめに
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
ディレクトリ構成
reinventing_the_wheel
└src
├Controller :プレゼンテーション層[MVCのC]
├Domain
│├Models :ドメインモデル層
│├Services :ドメインサービス層
│└Shared :例外等の共通ドメイン
├Infrastructure :インフラ層
│├CakePHP :`find('all')`等をこの中のクラスだけで使う
│└InMemory :テスト用(※これから実装予定[2021-05-09時点])
├Model :MVCのM
└UseCase :ユースケース(アプリケーションサービス)層
ユースケース図
ユーザーの CRUD を実装しています。
権限チェック処理はないので、admin
というのは不適切ですが…今後実装するということで。
APIについて
http://localhost/api/v1/users
の REST full な感じのルーティングとしています。
詳しくは、本リポジトリのreinventing_the_wheel/docs/api/src/reference/users-v1.html
をブラウザで開くことにより確認できます。
実装
すべてのソースコードを載せると30ファイルくらいになってしまうので、「ユーザーを登録する」ユースケースの処理の流れの一部のみを記載します。
こうした方がスマートでない?等のご意見、いただけると嬉しいです!
また、 users テーブル・モデルは作成済、という体で進めます。
プレゼンテーション層
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']);
}
}
}
ユースケース層
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 を定義しています。
ドメインサービス層
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;
}
}
ユーザーの存在チェックを行います。
ドメイン層
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
を定義しています。
インフラ層
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 するエンジニアに少しでも役立てば嬉しいです。
本記事のコードレビューしていただいた会社の先輩に感謝します。