概要
Symfony4を使う案件に入ることになりました。
そこで、Doctrineを使ってDDDのAggregateを実装することにチャレンジしてみました。
なお、今回の実装は下記の記事の影響を非常に大きく受けています。
Doctrine ORM and DDD aggregates
目標
Doctrineを使用するにあたって、下記のポイントを満たすようにしたいと思います。
Repositoryの実装をDoctrine以外に切り替えられるようにする
ドメインモデルの実装が永続化の方法に依存しないようにすることが目的です。
依存関係逆転の原則(DIP)に従い、Repositoryの実装を切り替えられるようにしていきます。
今回はPHPUnitの実行時にインメモリのRepositoryを使用できるようにします。
Entity, Value ObjectとRDBのテーブルが1:1の制限を受けないようにする
Entity, Value Object 1クラスに対してテーブル1個が紐づくという制限があると、ドメインモデルのリファクタリングに制約が発生します。
例えば、あるValue Objectをいくつかのクラスに細分化するときに、テーブルも合わせて分割することになると影響範囲が非常に大きく、リファクタリングが容易にできなくなってしまいます。
今回はいくつかのValue Objectを、Value Objectを持つEntityと同じテーブルに格納できるようにします。
題材
今回は題材として、"注文"を取り扱うことにします。
ドメインモデルの設計
まず、ドメインモデルの設計から始めます。
テーブル構成の設計よりも先にドメインモデルの設計を行う理由は、ドメインの振る舞いにRDBの影響が入ることを防ぐためです。
ドメインモデルを考慮してデータモデルを設計するのであって、ドメインモデルがデータモデルの設計に縛られるようではいけない。
ヴァーン・ヴァーノン. 実践ドメイン駆動設計 翔泳社.
下記は、今回の題材である"注文"を簡素化したクラス図を描いたものです。
注文Entity(PurchaseOrder
)に対していくつかの明細Entity(Line
)が紐づき、どちらのEntityも属性としてValue Object(PurchaseOrderId
, ShippingAddress
, ProductId
)と持っているという形になっています。
ドメインモデルの実装
上記のクラス図をもとに、PHPでの実装を行いました。
この時点ではORMであるDoctrineにかかわる記述は含まれていません。
final class PurchaseOrder
{
/**
* @var PurchaseOrderId
*/
private $purchaseOrderId;
/**
* @var ShippingAddress
*/
private $shippingAddress;
/**
* @var int
*/
private $shippingFee;
/**
* @var Line[]
*/
private $lines;
private function __construct(
PurchaseOrderId $purchaseOrderId,
ShippingAddress $shippingAddress,
int $shippingFee
) {
$this->purchaseOrderId = $purchaseOrderId;
$this->shippingAddress = $shippingAddress;
$this->shippingFee = $shippingFee;
}
public static function create(
PurchaseOrderId $purchaseOrderId,
ShippingAddress $shippingAddress,
int $shippingFee
): self {
return new self($purchaseOrderId, $shippingAddress, $shippingFee);
}
public function addLine(
ProductId $productId,
int $orderedQuantity
): void {
$lineNumber = count($this->lines) + 1;
$this->lines[] = new Line($lineNumber, $productId, $orderedQuantity);
}
}
final class Line
{
/**
* @var int
*/
private $lineNumber;
/**
* @var ProductId
*/
private $productId;
/**
* @var int
*/
private $quantity;
public function __construct(
int $lineNumber,
ProductId $productId,
int $quantity
) {
$this->lineNumber = $lineNumber;
$this->productId = $productId;
$this->quantity = $quantity;
}
}
final class PurchaseOrderId
{
/**
* @var string
*/
private $id;
public function __construct(string $id)
{
$this->id = $id;
}
public function getId(): string
{
return $this->id;
}
public function __toString(): string
{
return $this->id;
}
}
final class ShippingAddress
{
/**
* @var string
*/
private $zipCode;
/**
* @var string
*/
private $prefecture;
/**
* @var string
*/
private $city;
public function __construct(
string $zipCode,
string $prefecture,
string $city
) {
$this->zipCode = $zipCode;
$this->prefecture = $prefecture;
$this->city = $city;
}
}
final class ProductId
{
/**
* @var string
*/
private $id;
public function __construct(string $id)
{
$this->id = $id;
}
public function getId(): string
{
return $this->id;
}
}
Repository Interfaceを作る
続いて、ドメインモデルを永続化するためのRepositoryのインターフェースを作成していきます。
今回Repositoryに求めるものは
-
PurchaseOrderId
を生成すること -
PurchaseOrder
インスタンスを渡して、永続化すること -
PurchaseOrderId
インスタンスを渡して、永続化したPurchaseOrder
オブジェクトを読み込むこと
の3点です。
インターフェースは下記のようになりました。
interface PurchaseOrderRepository
{
public function nextIdentify(): PurchaseOrderId;
public function findBy(PurchaseOrderId $id): ?PurchaseOrder;
public function save(PurchaseOrder $purchaseOrder): void;
}
インメモリのRepositoryを実装する
Repositoryのインターフェースを実装できたので、テスト用のインメモリのRepositoryを実装していきます。
class InMemoryPurchaseOrderRepository implements PurchaseOrderRepository
{
/**
* @var array
*/
private $store;
public function nextIdentify(): PurchaseOrderId
{
return new PurchaseOrderId(Uuid::uuid4()->toString());
}
public function findBy(PurchaseOrderId $id): ?PurchaseOrder
{
if (!isset($this->store[$id->getId()])) {
return null;
}
return $this->store[$id->getId()];
}
public function save(PurchaseOrder $purchaseOrder): void
{
$this->store[$purchaseOrder->getPurchaseOrderId()] = $purchaseOrder;
}
}
インメモリなので実際には永続化はできておらず、本番環境で使用されることはありませんが、その分高速なので単体テストで使用します。
下記のような形で使用しています。
class CreatePurchaseOrderUseCaseTest extends TestCase
{
public function testHandle()
{
// インメモリのRepositoryを用意
$repository = new InMemoryPurchaseOrderRepository();
// データ準備
$inputData = new CreatePurchaseOrderInputData(
'order-number-001',
'000-0000',
'○○県',
'××市',
[
new CreateOrderLineInputData('PRODUCT-ID-001', 5),
new CreateOrderLineInputData('PRODUCT-ID-002', 1),
]
);
/*
新規注文作成のユースケース.
PurchaseOrderRepositoryインターフェースを使用して永続化を行っている.
テスト時はコンストラクタでインメモリのRepositoryを渡している.
*/
$useCase = new CreatePurchaseOrderUseCase($repository);
// テストメソッドを実行
$outputData = $useCase->handle($inputData);
// 保存結果をチェック
$this->assertTrue($outputData->isSuccess());
// 保存されたモデルを読み込んで内容チェック
$order = $repository->findBy(new PurchaseOrderId($outputData->generatedPurchaseId()));
$this->assertSame('order-number-001', $order->getOrderNumber());
...
}
}
ドメインモデル、Repositoryインターフェース、テストの実装ができました。
ここからDoctrineを用いた永続化方法を実装していきます。
テーブル設計
ドメインモデルの設計・実装が終わったら、次に永続化するテーブルの設計をします。
今回は単純に正規化を行い、purchase_orders
, purchase_order_lines
の2テーブルとしました。
この際、Value ObjectであるShippingAddrress
はテーブルを分割しておらず、Entity, Value Objectとテーブルが1:1になっていません。
1. ドメインモデルをDctrine Entityとしてマークする
Doctrineエンティティとしてテーブルとの紐づけを行います。
方法として、YAML, XMLでの定義も可能ですが、今回はAnnotationを使用します。
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
* @ORM\Table(name="purchase_orders")
*/
final class PurchaseOrder
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
* @ORM\Table(name="purchase_order_lines")
*/
final class Line
2. プロパティをテーブルのカラムにマッピングする
続いて、Entityのプロパティをテーブルのカラムにマッピングしていきます。
こちらもYAML, XMLでの定義も可能ですが、今回はAnnotaionを使用します。
/**
* @var string
* @ORM\Column(type="string")
*/
private $orderNumber;
IDはDBのAuto Incrementな連番を使用するかによって記述方法が異なります。
今回は、下記の方針で実装してみます。
- PurchaseOrder.phpは、プログラム側で発番したUUIDを使用する
- Line.phpはDBが発番したAuto Incrementな連番を使用する
プログラム側で発番したIDを使用する場合、DBに自動発行の連番を使用しないように伝える必要があるので、@ORM\GeneratedValue(strategy="NONE")
を指定します。
/**
* @var PurchaseOrderId
* @ORM\Id()
* @ORM\GeneratedValue(strategy="NONE")
* @ORM\Column(type="string")
*/
private $id;
PurchaseOrder::orderNumber
, Line::quantity
のような、int, string型のプロパティはDoctrineで用意されている型でのマッピングが可能です。
しかし、自分で定義したValue Objectについては単純にカラムにマッピングすることができず、一工夫加える必要があります。
2-1. 単一のプロパティで構成されるValue Objectをマッピングする
今回のPurchaseOrderId
など、単一のプロパティを持つValue Objectをテーブルのカラムにマッピングするために、DBAL Typeを定義するという方法があります。
まず、Doctrine\DBAL\Types\Type
を継承したCustom Type用のクラスを作成します。
namespace App\Types;
use App\Entity\PurchaseOrder\PurchaseOrderId;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
class PurchaseOrderIdType extends Type
{
const PURCHASE_ORDER_ID = 'purchase_order_id';
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
{
return $platform->getVarcharTypeDeclarationSQL($fieldDeclaration);
}
public function getName()
{
return self::PURCHASE_ORDER_ID;
}
public function convertToDatabaseValue($value, AbstractPlatform $platform)
{
return $value->getId();
}
public function convertToPHPValue($value, AbstractPlatform $platform)
{
return new PurchaseOrderId($value);
}
}
Custom Typeを定義したら、config/package/doctrine.yaml
を修正して、Custom Typeを読み込みます。
doctrine:
dbal:
...
+ types:
+ purchase_order_id: App\Types\PurchaseOrderIdType
その後、追加した型をAnnotationに書き込みます。
/**
* @var PurchaseOrderId
* @ORM\Id()
* @ORM\GeneratedValue(strategy="NONE")
- * @ORM\Column(type="string")
+ * @ORM\Column(type="purchase_order_id")
*/
private $id;
2-2. 複数のプロパティで構成されるValue Objectをマッピングする
ShippingAddress
のように、複数のプロパティを持つValue Objectについては、DBAL Typeを定義してシリアライズした結果を単一のカラムに保存するという方法が考えられます。
しかし、単項目ごとの検索ができなくなるなどの問題点があります。
別の方法として、DoctrineのEmbeddablesという機能を利用する方法もあるので、今回はこちらで実装していきます。
まず、PurchaseOrder::shippingAddress
プロパティに@ORM\Embedded
アノテーションを記載し、Value Objectのクラスを指定します。
/**
* @var ShippingAddress
+ * @ORM\Embedded(class="ShippingAddress")
*/
private $shippingAddress;
次に、ShippingAddress
クラスに``アノテーションを記載します。
/*
+ * @ORM\Embeddable()
*/
final class ShippingAddress
これで、purchase_orders
テーブルのカラム(shipping_address_zip_code
, shipping_address_prefecture
, shipping_address_city
)とマッピングが行われます。
3. 1:nの関係に、Doctrine Collectionを適用する
PurchaseOrder
とLine
の1:nの関係に、Doctrine Collectionを適用します。
まず、PurchaseOrder::lines
プロパティに、子クラスであるLine
の情報として、下記のAnnotationを記載します。
/**
* @var Collection|Line[]
* @ORM\OneToMany(
* targetEntity="Line",
* mappedBy="purchaseOrder",
* cascade={"PERSIST"}
* )
*/
private $lines;
続いて、Line
クラスに親クラスであるPurchaseOrder
の情報を記載します。
こちらは、プロパティを1つ追加します(コンストラクタも変更しますが、ここでは割愛します)。
/**
* @var PurchaseOrder
* @ORM\ManyToOne(targetEntity="PurchaseOrder")
*/
private $purchaseOrder;
これで、Aggregate rootであるPurchaseOrder
を保存したときに、Line
も合わせて保存されるようになります。
Doctrineを使用したRepositoryを実装する
最後はRepository Interfaceの実装として、Doctrineを使ったものを作成します。
class DoctrinePurchaseOrderRepository implements PurchaseOrderRepository
{
/**
* @var ObjectManager
*/
private $entityManager;
public function __construct(ObjectManager $entityManager)
{
$this->entityManager = $entityManager;
}
public function nextIdentify(): PurchaseOrderId
{
return new PurchaseOrderId(Uuid::uuid4()->toString());
}
public function findBy(PurchaseOrderId $id): ?PurchaseOrder
{
$className = array_slice(explode('\\', PurchaseOrder::class), -1)[0];
$repository = $this->entityManager->getRepository($className);
return $repository->find($id->getId());
}
public function save(PurchaseOrder $purchaseOrder): void
{
$this->entityManager->persist($purchaseOrder);
$this->entityManager->flush();
}
}
この実装を通して保存処理を行うことで、RDBにレコードが追加されました。
select * from purchase_orders\G
*************************** 1. row ***************************
id: 6dcdaca0-7a17-4ce0-b7cb-b523f9b04612
order_number: order-number-0001
shipping_address_zip_code: 165-0035
shipping_address_prefecture: Tokyo
shipping_address_city: Nakano-ku
created: 2019-08-13 00:59:36
modified: 2019-08-13 00:59:36
select * from purchase_order_lines\G
*************************** 1. row ***************************
id: 1
purchase_order_id: 6dcdaca0-7a17-4ce0-b7cb-b523f9b04612
line_number: 1
product_id: PRO-ID-001
quantity: 2
created: 2019-08-13 00:59:36
modified: 2019-08-13 00:59:36
*************************** 2. row ***************************
id: 2
purchase_order_id: 6dcdaca0-7a17-4ce0-b7cb-b523f9b04612
line_number: 2
product_id: PRO-ID-002
quantity: 1
created: 2019-08-13 00:59:36
modified: 2019-08-13 00:59:36
考察
今回はDoctrineを使用してDDDのAggregateを実装してみました。
やってみて気になる点としては、
ドメインモデルにDoctrineの情報が洩れている
ドメインモデルに対して、ドメインの振る舞いに関係のないDoctrineについての記述が入っています。
コメントとしてアノテーションを記載しているため、影響は比較的小さい(事実、インメモリRepositoryに切り替えて単体テストも実行可能でした)とは思いますが、気になるポイントです。
Doctrineを使用したRepositoryの単体テストが必須
Doctrineを使用したRepositoryはアノテーション・DBAL Type・Embeddablesに依存した挙動をします。
結構複雑なので、単体テストをしっかり書かないと危険という印象です。
単体テスト時にDBを使用するのは速度面が気になるので、インメモリのSQLite dbを使用したテストなどについても勉強していきたいと思います。
サンプル
今回実装した内容をGithubに置いたので、興味がある方はご覧ください。