Posted at

DoctrineでDDDのAggregateを実装してみた


概要

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の影響が入ることを防ぐためです。


ドメインモデルを考慮してデータモデルを設計するのであって、ドメインモデルがデータモデルの設計に縛られるようではいけない。

ヴァーン・ヴァーノン. 実践ドメイン駆動設計 翔泳社.


下記は、今回の題材である"注文"を簡素化したクラス図を描いたものです。

Doctrine_DDD_Aggregate_example_class.png!

注文Entity(PurchaseOrder)に対していくつかの明細Entity(Line)が紐づき、どちらのEntityも属性としてValue Object(PurchaseOrderId, ShippingAddress, ProductId)と持っているという形になっています。


ドメインモデルの実装

上記のクラス図をもとに、PHPでの実装を行いました。

この時点ではORMであるDoctrineにかかわる記述は含まれていません。


PurchaseOrder.php

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);
}
}



Line.php

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;
}
}



PurchaseOrderId.php

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;
}
}



ShippingAddress.php

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;
}
}



ProductId.php

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点です。

インターフェースは下記のようになりました。


PurchaseOrderRepository.php

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テーブルとしました。

Doctrine_DDD_Aggregate_example_table.png

この際、Value ObjectであるShippingAddrressはテーブルを分割しておらず、Entity, Value Objectとテーブルが1:1になっていません。


1. ドメインモデルをDctrine Entityとしてマークする

Doctrineエンティティとしてテーブルとの紐づけを行います。

方法として、YAML, XMLでの定義も可能ですが、今回はAnnotationを使用します。


PurchaseOrder.php

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity()
* @ORM\Table(name="purchase_orders")
*/

final class PurchaseOrder



Line.php

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity()
* @ORM\Table(name="purchase_order_lines")
*/

final class Line



2. プロパティをテーブルのカラムにマッピングする

続いて、Entityのプロパティをテーブルのカラムにマッピングしていきます。

こちらもYAML, XMLでの定義も可能ですが、今回はAnnotaionを使用します。


PurchaseOrder.php

/**

* @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")を指定します。


PurchaseOrder.php

/**

* @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を定義するという方法があります。

参考:Custom Mapping Types

まず、Doctrine\DBAL\Types\Typeを継承したCustom Type用のクラスを作成します。


PurchaseOrderIdType.php

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.yaml

doctrine:

dbal:
...
+ types:
+ purchase_order_id: App\Types\PurchaseOrderIdType

その後、追加した型をAnnotationに書き込みます。


PurchaseOrder.php

/**

* @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のクラスを指定します。


PurchaseOrder.php

/**

* @var ShippingAddress
+ * @ORM\Embedded(class="ShippingAddress")
*/
private $shippingAddress;

次に、ShippingAddressクラスに``アノテーションを記載します。


ShippingAddress.php


/*
+ * @ORM\Embeddable()
*/
final class ShippingAddress

これで、purchase_ordersテーブルのカラム(shipping_address_zip_code, shipping_address_prefecture, shipping_address_city)とマッピングが行われます。


3. 1:nの関係に、Doctrine Collectionを適用する

PurchaseOrderLineの1:nの関係に、Doctrine Collectionを適用します。

まず、PurchaseOrder::linesプロパティに、子クラスであるLineの情報として、下記のAnnotationを記載します。


PurchaseOrder.php

/**

* @var Collection|Line[]
* @ORM\OneToMany(
* targetEntity="Line",
* mappedBy="purchaseOrder",
* cascade={"PERSIST"}
* )
*/

private $lines;

続いて、Lineクラスに親クラスであるPurchaseOrderの情報を記載します。

こちらは、プロパティを1つ追加します(コンストラクタも変更しますが、ここでは割愛します)。


Line.php

/**

* @var PurchaseOrder
* @ORM\ManyToOne(targetEntity="PurchaseOrder")
*/

private $purchaseOrder;

これで、Aggregate rootであるPurchaseOrderを保存したときに、Lineも合わせて保存されるようになります。


Doctrineを使用したRepositoryを実装する

最後はRepository Interfaceの実装として、Doctrineを使ったものを作成します。


DoctrinePurchaseOrderRepository.php

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を使用したテストなどについても勉強していきたいと思います。



参考サイト