独立したコアレイヤパターン の適用は、クリーンアーキテクチャ、DDD を始めるための最初として、すごく良いアプローチだと思います。
たくさんの開発に関わっていると、必ずしも正しいことが良いことではないことに出くわす場面が多々あります。マイクロサービスにしたからと言って、体力(開発要員)が少ないのに多数のプログラミング言語を扱うようにするといざってときに困るし、独りよがり DDD をしても誰も得しないし、クリーンアーキテクチャを実践しようとして「あーでもない」「こーでもない」っと理想を求めた結果リリースできなかったり。一番はリリースして、改善し続けて、使ってもらう(金をもらう)ことが大切なので、それを忘れないようにする必要があります。
この「独立したコアレイヤパターン」は、ルールも明確だし、様々な状況に合わせて対応できる優れものだと思っています。
今回、この「独立したコアレイヤパターン」を CakePHP4 へ適用しようと思います。
この記事でわかること
- CakePHP4 へ独立したコアレイヤパターンを適用する方法
- ただし、マスタメンテ的な機能に対してパターンを適用するので、独立したコアレイヤパターンのありがたさの本質はわかりません。本質はパターンの記事を参照してください。
- この記事内のソースは以下で公開しています。
事前準備
- CakePHP4 で Swagger3 を使った API ドキュメントの書き方を考える( ref と schema でわけていく ) の記事内容のソースから発展させてます。
- docker-compose を実行し、コンテナを立ち上げます。
docker-compose up -d
バージョン | |
---|---|
docker | Docker version 19.03.8, build afacb8b |
docker-compose | docker-compose version 1.25.4, build 8d51620a |
CakePHP4 | 4.0.4 |
コアレイヤ用パッケージの追加
プロジェクトルート「cakephp-vue-study」の直下にコアレイヤ用パッケージを追加します。
cakephp-vue-study/
packages/
Cas/
Domain/
Test/
UseCase/
というディレクトリ階層で Cas
の下にコアレイヤのソースを配置します。
Cas
に意味はありません。好きなパッケージ名にしてください。
composer.json へ autoload するディレクトリを追加
composer.json の autoload へコアレイヤのディレクトリを追加します。
// ... snip
"autoload": {
"psr-4": {
"Cas\\": "packages/Cas/",
"App\\": "src/"
}
},
// ... snip
phpunit の対象とする
./phpunit.xml.dist
をコピーして、phpunit.xml
を作り、コアレイヤのテストを実行対象へ加えます。
// ... snip
<testsuites>
<testsuite name="app">
<directory>tests/TestCase/</directory>
<directory>packages/Cas/Test/</directory>
</testsuite>
</testsuites>
// ... snip
CodeSniffer や PHPStan の対象とする
composer.json の scripts を変更し、コアレイヤを対象とします。
そろそろ、コマンドが長くなってきたので設定ファイルを用意すべきかもしれませんが、もう少しコマンドでがんばります。
// ... snip
"scripts": {
// ... snip
"cs-check": "phpcs --colors -p --standard=vendor/cakephp/cakephp-codesniffer/CakePHP --exclude=Squiz.Commenting.DocCommentAlignment src/ tests/ packages/Cas/UseCase/ packages/Cas/Domain/",
"cs-fix": "phpcbf --colors --standard=vendor/cakephp/cakephp-codesniffer/CakePHP --exclude=Squiz.Commenting.DocCommentAlignment src/ tests/ packages/Cas/UseCase/ packages/Cas/Domain/",
"stan": "phpstan analyse src/ packages/Cas/UseCase/ packages/Cas/Domain/",
// ... snip
},
// ... snip
コアレイヤの実装
ValueObject の実装
単純ですが、重要となるタスク情報のIDを表現する TaskId
ValueObject を実装します。
ValueObjectOfTrait
, ValueObjectStringTrait
は、独立したコアレイヤパターンのソースから拝借しました。自分では絶対に思いつかないので、とても参考になりました。
<?php
declare(strict_types=1);
namespace Cas\Domain\Model;
class TaskId
{
use ValueObjectStringTrait;
use ValueObjectUuidTrait;
/**
* @param string $value value
*/
private function __construct(string $value)
{
$this->value = $value;
}
}
ValueObjectUuidTrait
は、独自で作りました。
コアレイヤはフレームワーク「CakePHP4」に依存しないようにすべきですが、いつでも入れかることができるとの判断で UUIDv4の生成は CakePHP4 の Textクラスを使っています。。。
自前で実装すべきか、ライブラリを入れるべきか、、、悩んでもういいやって感じで CakePHP4 にある Text を選びました。
自前で実装すべきか、ライブラリを入れるべきかの自分なりの結論がでたら、その方式へ変更します。
<?php
declare(strict_types=1);
namespace Cas\Domain\Model;
use Cake\Utility\Text;
trait ValueObjectUuidTrait
{
/**
* @return self
*/
public static function newId(): self
{
return new self(Text::uuid());
}
}
拝借した ValueObjectOfTrait
, ValueObjectStringTrait
です。
<?php
declare(strict_types=1);
namespace Cas\Domain\Model;
trait ValueObjectOfTrait
{
/**
* @param mixed $value value
* @return self
*/
public static function of($value): self
{
if ($value instanceof static) {
return $value;
}
return new self($value);
}
}
<?php
declare(strict_types=1);
namespace Cas\Domain\Model;
trait ValueObjectStringTrait
{
use ValueObjectOfTrait;
/**
* @var string
*/
private $value;
/**
* @param string $value value
*/
private function __construct(string $value)
{
$this->value = $value;
}
/**
* @return string
*/
public function asString(): string
{
return $this->value;
}
/**
* @return string
*/
public function __toString(): string
{
return $this->asString();
}
}
ドメインモデルの実装
タスクを表現するモデルです。
コンストラクタでバリデーションは実施するべきなのは理解できつつあります。(実装はしてないですが、、、)
description
を ValueObject にすべきかは、、、わかんない。
toArray()
も実装しています。これは Adapter で使うためです。
<?php
declare(strict_types=1);
namespace Cas\Domain\Model;
class Task
{
/**
* @var \Cas\Domain\Model\TaskId
*/
private $id;
/**
* @var string
*/
private $description;
/**
* @param \Cas\Domain\Model\TaskId $id id
* @param string $description description
*/
public function __construct(TaskId $id, string $description)
{
$this->id = $id;
$this->description = $description;
}
/**
* @return array{id:string, description:string}
*/
public function toArray(): array
{
return [
'id' => $this->id->asString(),
'description' => $this->description,
];
}
}
ユースケースの実装とポートの定義
タスク登録のユースケースを実装します。
マスタメンテ的な機能なのでロジックがなくて、ユースケースの意味はないかもしれません。。。
でも、実際の開発であれば、ここでいろいろなことがわかるはず。
ユースケースの引数は、ValueObject またはプリミティブ型にすべきと思います。
返却値はドメインモデルではないほうが良い気がするのですが、悩み中です。
この返却のためだけに DTO を作ることは納得できていない感じです。いっそ、ハッシュテーブル(PHPのArray)で返しちゃうってのもありかなっとも思っています。
<?php
declare(strict_types=1);
namespace Cas\UseCase\Task;
use Cas\Domain\Model\Task;
use Cas\Domain\Model\TaskId;
use Cas\UseCase\TransactionPort;
/**
* CreateTask
*/
class CreateTask
{
/**
* @var \Cas\UseCase\Task\CreateTaskCommandPort
*/
private $command;
/**
* @var \Cas\UseCase\TransactionPort
*/
private $transaction;
/**
* @param \Cas\UseCase\Task\CreateTaskCommandPort $command command
* @param \Cas\UseCase\TransactionPort $transaction transaction
*/
public function __construct(CreateTaskCommandPort $command, TransactionPort $transaction)
{
$this->command = $command;
$this->transaction = $transaction;
}
/**
* @param string $description description
* @return \Cas\Domain\Model\Task
*/
public function execute(string $description): Task
{
return $this->transaction->transactional(function () use ($description) {
$task = new Task(TaskId::newId(), $description);
return $this->command->create($task);
});
}
}
CommandPort
と TransactionPort
です。
CommandPort
は、ドメインモデルを永続化させるって感じにしたいので、ドメインモデルを引数として受け取るようにしています。
<?php
declare(strict_types=1);
namespace Cas\UseCase\Task;
use Cas\Domain\Model\Task;
interface CreateTaskCommandPort
{
/**
* @param \Cas\Domain\Model\Task $task task
* @return \Cas\Domain\Model\Task
*/
public function create(Task $task): Task;
}
<?php
declare(strict_types=1);
namespace Cas\UseCase;
interface TransactionPort
{
/**
* @param callable $callback callback
* @return mixed
*/
public function transactional(callable $callback);
}
テストの実装
CreateTask
のテストです。
各Port をテスト用に実装することで高速なテストを行うことができます。
<?php
declare(strict_types=1);
namespace Cas\Test\UseCase\Task;
use Cas\Domain\Model\Task;
use Cas\UseCase\Task\CreateTask;
use Cas\UseCase\Task\CreateTaskCommandPort;
use Cas\UseCase\TransactionPort;
use PHPUnit\Framework\TestCase;
class CreateTaskTest extends TestCase
{
/**
* @return void
*/
public function test_登録できること(): void
{
// Arrange
$useCase = new CreateTask(
new class implements CreateTaskCommandPort
{
public function create(Task $task): Task
{
return $task;
}
},
new class implements TransactionPort
{
public function transactional(callable $callback)
{
return $callback();
}
}
);
$description = 'created';
// Act
$actual = $useCase->execute($description);
// Assert
$this->assertEquals($description, $actual->toArray()['description']);
}
}
サービスレイヤの実装
Adapter の実装
CreateTaskCommandPort
を実装する CreateTaskAdapter
を作ります。
ここは CakePHP4 の世界です。
今回、ID を CakePHP4 で自動採番するのではなく、コアレイヤで採番された ID を使います。そのため save()
メソッドで checkExisting
オプションを false
にして、運悪く(本当に運がすごく悪く) UUID が衝突した場合、update
せずに例外( PDOException
)を投げるようにしています。
今回 Task
ドメインモデルには、Getter は作っていません。ドメインの処理のために必要であれば作るべきと思うのですが、Adapter のために Getter を設けるのは違和感があります。
どうすればよいかわからず toArray()
を実装して使っています。
<?php
declare(strict_types=1);
namespace App\Controller\Api\Task;
use Cake\ORM\Locator\LocatorAwareTrait;
use Cas\Domain\Exception\DomainSystemException;
use Cas\Domain\Model\Task;
use Cas\UseCase\Task\CreateTaskCommandPort;
class CreateTaskAdapter implements CreateTaskCommandPort
{
use LocatorAwareTrait;
/**
* @param \Cas\Domain\Model\Task $task task
* @return \Cas\Domain\Model\Task
*/
public function create(Task $task): Task
{
$Tasks = $this->getTableLocator()->get('Tasks');
/** @var \App\Model\Entity\Task $taskEntity */
$taskEntity = $Tasks->newEmptyEntity();
$taskArray = $task->toArray();
$taskEntity->id = $taskArray['id'];
$taskEntity->description = $taskArray['description'];
if (!$Tasks->save($taskEntity, ['atomic' => false, 'checkExisting' => false])) {
throw new DomainSystemException("登録できませんでした。");
}
return $taskEntity->toModel();
}
}
テストは以下です。
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Model\Table;
use App\Controller\Api\Task\CreateTaskAdapter;
use App\Model\Table\TasksTable;
use Cake\ORM\TableRegistry;
use Cake\TestSuite\TestCase;
use Cas\Domain\Model\Task;
use Cas\Domain\Model\TaskId;
use PDOException;
/**
* App\Controller\Api\Task\CreateTaskAdapter Test Case
*/
class CreateTaskAdapterTest extends TestCase
{
/**
* Fixtures
*
* @var array
*/
protected $fixtures = [
'app.Tasks',
];
/**
* @var \App\Model\Table\TasksTable
*/
protected $Tasks;
/**
* @var \App\Controller\Api\Task\CreateTaskAdapter
*/
protected $adapter;
/**
* setUp method
*
* @return void
*/
public function setUp(): void
{
parent::setUp();
$config = TableRegistry::getTableLocator()->exists('Tasks') ? [] : ['className' => TasksTable::class];
$this->Tasks = TableRegistry::getTableLocator()->get('Tasks', $config);
$this->adapter = new CreateTaskAdapter();
}
/**
* tearDown method
*
* @return void
*/
public function tearDown(): void
{
unset($this->adapter);
unset($this->Tasks);
parent::tearDown();
}
/**
* @return void
*/
public function test_指定したUUIDで登録できること(): void
{
// Arrange
$taskId = TaskId::of('aa08b42c-815b-49f4-b3ec-1b14713ceb69');
// Act
$this->adapter->create(new Task($taskId, 'created'));
// Assert
$this->assertNotNull($this->Tasks->get($taskId->asString()));
}
/**
* @return void
*/
public function test_同じUUIDの情報を登録できないこと(): void
{
// Arrange
$taskId = TaskId::of('aa08b42c-815b-49f4-b3ec-1b14713ceb69');
$this->adapter->create(new Task($taskId, 'created'));
// Expect
$this->expectException(PDOException::class);
// Act
$this->adapter->create(new Task($taskId, 'double'));
}
}
TransactionAdapter の実装は以下です。
<?php
declare(strict_types=1);
namespace App\Adapter;
use Cake\Datasource\ConnectionManager;
use Cas\UseCase\TransactionPort;
class TransactionAdapter implements TransactionPort
{
/**
* @param callable $callback callback
* @return mixed
*/
public function transactional(callable $callback)
{
$connection = ConnectionManager::get('default');
return $connection->transactional($callback);
}
}
Controller からユースケースを実行する
CreateTaskController
から CreateTask
ユースケースを呼び出します。
CreateTaskController
の振る舞いは変わらないので、CreateTaskControllerTest
を変更する必要はありません。
<?php
declare(strict_types=1);
namespace App\Controller\Api\Task;
use App\Adapter\TransactionAdapter;
use App\Controller\Api\ValidationErrorResponseForm;
use App\Controller\AppController;
use Cas\UseCase\Task\CreateTask;
/**
* CreateTaskController
*
* @property \App\Model\Table\TasksTable $Tasks
*/
class CreateTaskController extends AppController
{
/**
* @OA\Post(
* path="/api/ca-task/create.json",
* tags={"CaTask"},
* summary="タスクを登録する",
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/CreateTaskRequestForm"),
* ),
* @OA\Response(
* response="200",
* ref="#/components/responses/CreateTaskResponseForm",
* ),
* @OA\Response(
* response="403",
* ref="#/components/responses/ValidationErrorResponseForm",
* ),
* )
*
* @return void
*/
public function execute(): void
{
$requestForm = new CreateTaskRequestForm();
if (!$requestForm->execute($this->request->getData())) {
ValidationErrorResponseForm::error($this, $requestForm->getErrors());
return;
}
$adapter = new CreateTaskAdapter();
$transaction = new TransactionAdapter();
$useCase = new CreateTask($adapter, $transaction);
$task = $useCase->execute($requestForm->description());
$responseForm = new CreateTaskResponseForm();
$responseForm->execute(['task' => $task->toArray()]);
$responseForm->response($this);
}
}
まとめ
独立したコアレイヤパターン の恩恵を受けることができないようなタスク登録の処理でしたが、作っているときは、UseCaseのテスト、Adapterのテストをテンポよく書きながら実装できました。
特に気になる点(今回だと CreateTaskAdaper
の ID まわりの動き)をすぐに確認できたことが良かったです。
実際の開発では、メンバーと認識をあわせながら、成長しつつ作る必要があります。
この独立したコアレイヤパターンは、それが十分にできる作りだと感じました。
開発にはそれぞれのフェーズがあると思います。急ぐ場合は、急いだ作るにする必要があります。でも、急いだ開発もいずれ改善フェーズに入り重視すべきスケジュールから保守性へかわります。そのときにこのパターンを適用して、技術的負債の返却を行うこともできます。
ただし、テストのないリファクタリングはできないので、どんなに急いでいても Controller のテストは書いておきたいですね。