DDDの実務への落とし込み(with PHP)

前置き

こんにちは。うしおです。
トラストバンク Advent Calendar 2022の7日目を担当させていただきます。

DDDやクリーンアーキテクチャを実践するにあたり、個々のテクニックは理解できても実務へ落とし込みがうまくできないといった悩みを抱えていませんか?
わたしは抱えています。ならばモデリングの練習だ、ということでさっそくやってみましょう。

今回の主題はモデリングとコードへの落とし込みのため、InfrastructureとPresentationはありません。従ってLaravelも登場しません。
が、もちろんLaravelアプリケーションへの組み込みをゴールとして設計します。

ソースコードGithubへ置きますので、見ながら読み進めてみてください。

題材

モデリングをするからには業務はある程度複雑でないといけません。
架空の食品販売会社を作りましょう。nicoshopと名付けました。
顧客から注文を受けた食品を独自ルートで仕入れて販売します。

モデリング

まずは業務のコンテキストを分割してみます。

  1. 販売
  2. 仕入

としましょう。
さらに各コンテキストの業務知識を挙げていきます。
必ずドメインロジックとして表現したいものを赤字にしました。
~ができる、~ができない、~のとき~する、などのルールに目を付けます。

1. 販売

アクター
  • 顧客
  • 社内スタッフ
業務内容
  • 顧客は注文を行う。
    • 注文には1つ以上の明細が必要。
    • 注文1つに明細は100まで指定可能。
    • 明細には商品と数量を指定する。
      • 顧客は商品をカタログから選ぶ。
    • 商品あたり1000000個以上は販売不可。
    • 注文の中で商品は重複不可。
    • 注文日は当日から変更不可。
    • 注文作成時は未受付、未完了。
  • 社内スタッフは注文を一覧で確認し、受付や完了を行う。
    • 一度受け付けた注文は再受付はできない。
      • 注文が受付されないまま1日経過していたらリマインドする。
    • 社内スタッフが出荷したら注文を完了とする。
      • 未受付の注文は完了できない。
      • 一度完了した注文は再完了はできない。
      • 出荷業務はシステムのスコープ外で行う。

2. 仕入

アクター
  • 社内スタッフ
  • サプライヤ
業務内容
  • 社内スタッフはサプライヤに対して注文を行う。
    • 注文には商品を1つのみ指定可能。
      • 社内スタッフは商品をカタログから選ぶ。
    • 注文には商品と数量を指定する。
      • 数量はロット単位でのみ注文可能。
      • 商品にはそれぞれロット数が設定されている。
  • 商品が入荷したら出荷を行い注文を完了とする。
      • 一度完了した注文は再完了はできない。
    • 入荷・出荷業務はシステムのスコープ外で行う。

クラス設計

まずは販売業務のクラス設計をします。
最終的なクラス図はこちらになります。

クラス図

「注文」をエンティティとしてみると、属性は下記のような感じでしょうか。

  • 識別ID
  • 注文日
  • 明細
    • 商品
    • 数量
  • 顧客
  • 受付済みかどうか
  • 完了かどうか

「商品」は値オブジェクトとしておきます。

  • 商品番号
  • 商品名
  • 販売単価
<?pnp
// app/Contexts/Sales/Domain/Entity/Order.php
// 注文に関する全てのドメイン知識を凝集したいクラス
final class Order
{
    /**
     * @param Order\Item[] $items
     */
    private function __construct(
        private readonly int|null $id, // ID
        private readonly DateTimeImmutable $date, // 注文日
        private array $items, // 明細
        private readonly int $customerUserId, // 注文者のID
        private bool $accepted, // 受付済みか
        private bool $finished, // 完了済みか
    )
    {
        // メンバー変数は全て非公開。振る舞いだけを公開する。
        // publicにしたりgetterを公開したりすると、その情報を使って他のクラスがドメインロジックを実行できてしまう(ドメイン知識の流出)。
        // コンストラクタも公開せず、インスタンス生成手段はcreateメソッドかrestoreメソッドのみとする。
    }
}

// app/Contexts/Sales/Domain/Entity/Order/Item.php
final class Item
{
    public function __construct(
        public readonly Product $product, // 商品
        public readonly int $quantity, // 数量
    )
    {
        // 現段階では単なるデータ参照クラス
        // 明細に関するドメイン知識がでたらこのクラスへ実装する
    }
}

// app/Contexts/Sales/Domain/Value/Product.php
final class Product
{
    public function __construct(
        public readonly int $id, // 商品ID
        public readonly string $name, // 商品名
        public readonly int $unitPrice, // 販売単価
    )
    {
        // 商品マスタに相当するデータクラス
        // 販売コンテキストにおいては商品には振る舞いは無く、単なる値とみなせる
    }

    // 値オブジェクトとして比較が行えるようにする
    public function equals(self $product): bool
    {
        return $this->id === $product->id;
    }
}

振る舞いは下記になるでしょうか。

  • 作成
  • 明細追加
  • 受付
    • 受付のリマインド
  • 完了
<?pnp
final class Order
{
    public static function create();

    public function add();

    public function accept();

    public function remind();

    public function done();
}

ひとつずつ掘り下げていきます。

作成

注文日は当日から変更不可。
注文作成時は未受付、未完了。

とあるので、パラメータとして必要なのは注文者が誰か?という情報だけで良さそうですね。
他は全て初期値を設定します。

<?pnp
public static function create(
    int $customerUserId,
): self
{
    return new self(
        id: null, // 永続化までID無し
        date: new DateTimeImmutable(), // 当日
        items: [], // 作成時は空
        customerUserId: $customerUserId,
        accepted: false, // 未受付
        finished: false, // 未完了
    );
}

明細追加

関係しそうな業務知識はこちらでしょうか。

注文1つに明細は100まで指定可能。
明細には商品と数量を指定する。
商品あたり1000000個以上は販売不可。
注文の中で商品は重複不可。

<?pnp
public function add(
    Product $product,
    int $quantity,
): void
{
    // 事前条件として業務ルールを守れているか確認する
    if (count($this->items) >= 100) {
        // 注文に100を超える明細は登録不可
        throw new InvalidArgumentException('The order must not have more than 100 items.');
    }
    if ($this->has($product)) {
        // 商品は重複不可
        throw new InvalidArgumentException('The product has already been taken.');
    }
    // 問題なければ明細として保持する
    $this->items[] = new Order\Item($product, $quantity);
}

Itemクラスの方にもバリデーションが必要ですね。

<?pnp
final class Item
{
    public function __construct(
        public readonly Product $product,
        public readonly int $quantity,
    )
    {
        if ($quantity >= 1000000) {
            // 商品の数量に1000000以上は登録不可
            throw new InvalidArgumentException('The item quantity must not have more than 1000000.');
        }
    }
}

受付

一度受け付けた注文は再受付はできない。

<?pnp
public function accept(): void
{
    if ($this->accepted) {
        // 二重の受付は不可
        throw new BadMethodCallException('The order has already been accepted.');
    }
    $this->accepted = true;
}

受付リマインド

注文が受付されないまま1日経過していたらリマインドする。

受付されないまま1日経過していたら注文が作成されたことを社内スタッフへ通知しましょう。

<?pnp
// ユースケースとしては、バッチみたいなものから定期的に呼ばれることになる想定
public function remind(OrderCreated $created): void
{
    if ($this->accepted || $this->finished) {
        // 受付済み、完了済みはリマインド対象外
        return;
    }
    $now = new DateTimeImmutable();
    if ($now > $this->date->modify('tomorrow')) {
        $created->notify();
    }
}

完了

未受付の注文は完了できない。
一度完了した注文は再完了はできない。

<?pnp
public function done(): void
{
    if (!$this->accepted) {
        // 受付前の注文は完了不可
        throw new BadMethodCallException('The order not yet accepted.');
    }
    if ($this->finished) {
        // 二重の完了は不可
        throw new BadMethodCallException('The order has already done.');
    }
    $this->finished = true;
}

永続化と復元

加えて、エンティティ自身の永続化と復元も実装します。

<?pnp
// エンティティの属性を公開しないため、リポジトリを受け取る
public function save(OrderRepository $repository): void
{
    // 永続化のタイミングでも整合性を確認する
    if (empty($this->items)) {
        // 注文には1つ以上の明細が必要
        throw new InvalidArgumentException('Cannot order without items');
    }
    $repository->save(new OrderRecord(
        id: $this->id,
        date: $this->date,
        items: $this->items,
        customerUserId: $this->customerUserId,
        accepted: $this->accepted,
        finished: $this->finished,
    ));
}

// リポジトリで復元されたデータからインスタンスを生成する
public static function restore(OrderRecord $record): self
{
    return new self(
        id: $record->id,
        date: $record->date,
        items: $record->items,
        customerUserId: $record->customerUserId,
        accepted: $record->accepted,
        finished: $record->finished,
    );
}

// app/Contexts/Sales/Domain/Persistence/OrderRecord.php
// エンティティの永続化データをリポジトリとやりとりするためのDTO
final class OrderRecord
{
    /**
     * @param Order\Item[] $items
     */
    public function __construct(
        public readonly int|null $id,
        public readonly DateTimeImmutable $date,
        public readonly array $items,
        public readonly int $customerUserId,
        public readonly bool $accepted,
        public readonly bool $finished,
    )
    {

    }
}

// app/Contexts/Sales/Domain/Persistence/OrderRepository.php
interface OrderRepository
{
    public function save(OrderRecord $record): void;

    public function findById(int $id): OrderRecord;
}

ユースケース

「注文」エンティティが一通り設計できたら、利用するユースケースを考えます。

  • 新規作成
  • 受付
  • リマインド
  • 完了

また、それぞれのユースケースを実行するにあたり、商品一覧参照や注文一覧参照も必要でしょう。

新規作成

パラメータは顧客と明細ですね。

<?pnp
// app/Contexts/Sales/Application/UseCase/Order/Store/Input.php
// Presentationレイヤからのデータをユースケースのパラメータへ変換する
final class Input
{
    /**
     * @param Input\OrderItem[] $items
     */
    private function __construct(
        public readonly array $items,
        public readonly int $customerUserId,
    )
    {

    }

    // ユースケース入力を生成する
    // $inputはHTTP POSTで受け取るようなデータを想定
    public static function fromArray(array $input): self
    {
        return new self(
            items: array_map(function (array $itemInput) {
                return new Input\OrderItem(
                    intval($itemInput['product_id']),
                    intval($itemInput['quantity']),
                );
            }, $input['items']),
            customerUserId: $input['user_id'],
        );
    }
}

// app/Contexts/Sales/Application/UseCase/Order/Store/Input/OrderItem.php
// 配列として受け取る明細データ
final class OrderItem
{
    public function __construct(
        public readonly int $productId,
        public readonly int $quantity,
    )
    {

    }
}

明細の商品はIDで受け取るため、商品オブジェクトを復元します。
次に注文エンティティを作成し、明細を追加します。
最後に永続化を行います。

<?pnp
final class Interactor
{
    public function __construct(
        private readonly OrderRepository $orderRepository, // 注文リポジトリ
        private readonly ProductQuery $productQuery, // 商品クエリ
    )
    {
        // DIで注入される依存クラス
        // 注文リポジトリはドメインエンティティを扱う
        // 商品クエリはユースケース特有の参照データを扱う
    }

    public function execute(Input $input): void
    {
        // Order::addは商品オブジェクトを要求するため、商品を検索する
        $products = $this->productQuery->filterByIds(
            // foreachで回してid配列を作るのと等価
            array_map(function (Input\OrderItem $orderItem) {
                return $orderItem->productId;
            }, $input->items)
        )->execute();

        // 注文作成
        $order = Order::create($input->customerUserId);
        foreach ($input->items as $orderItem) {
            // 明細追加
            $order->add($products->getById($orderItem->productId), $orderItem->quantity);
        }
        // エンティティの永続化
        $this->orderRepository->save($order->toSaveRecord());
    }
}

商品IDから商品オブジェクトを復元するためのクエリは下記のようにしてみました。
顧客が商品を参照するときのユースケースでも利用するため、ページネーションを行えるようにします。

<?pnp
// app/Contexts/Sales/Application/Persistence/ProductQuery.php
// チェーンメソッドでfiterXXX->paginate->executeみたいに呼ばれる想定
interface ProductQuery
{
    /**
     * @param int[] $ids
     */
    public function filterByIds(array $ids): self; // IDによる絞り込み

    public function paginate(int $perPage, int $currentPage): self; // ページ分割

    public function execute(): ProductPaginator; // クエリ実行
}

// app/Contexts/Sales/Application/Persistence/ProductPaginator.php
// foreachで回せるインターフェースと、ページネーションで利用する属性を持つ
interface ProductPaginator extends Iterator
{
    public function current(): Product; // Iteratorから継承

    public function total(): int; // ページネーション用

    public function perPage(): int; // ページネーション用

    public function currentPage(): int; // ページネーション用

    public function getById(int $id): Product; // ユースケース用
}

テスト

出来上がったクラスをPHPUnitでテストしましょう。
まずは準備です。

composer.jsonを作成します。

{
  "name": "seasalt/nicoshop",
  "type": "project",
  "description": "ddd sample",
  "keywords": ["ddd"],
  "license": "MIT",
  "require": {
    "php": "^8.1"
  },
  "autoload": {
    "psr-4": {
      "App\\": "app/"
    }
  }
}

続いてPHPUnitをインストール。

docker run --rm -it -v "$(pwd):/app" composer/composer require phpunit/phpunit

テストコードのautoloadを追加。

  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }

phpunit.xmlも作成します。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory>./tests/Unit</directory>
        </testsuite>
    </testsuites>
</phpunit>

これでテストを実施する準備が整いました。

テストコードを実装します。
こんな感じのテストケースを書きたいですが、注文リポジトリや商品クエリのモックを用意しないといけませんね。

<?pnp
public function testCanOrder(): void
{
    // 前準備
    $orderRepository = $this->createOrderRepository();
    $interactor = new Interactor(
        $orderRepository,
        $this->createProductQuery(),
    );

    // 実行
    $interactor->execute(Input::fromArray([
        'items' => [
            [
                'product_id' => 1234,
                'quantity' => 100,
            ],
            [
                'product_id' => 555,
                'quantity' => 1,
            ],
            [
                'product_id' => 778899,
                'quantity' => 99999,
            ],
        ],
        'user_id' => 1,
    ]));

    // 検証
    $storedOrder = $orderRepository->findById(1);
    $this->assertSame(1234, $storedOrder->items[0]->product->id);
    $this->assertSame('ポテチ', $storedOrder->items[0]->product->name);
    $this->assertSame(100, $storedOrder->items[0]->quantity);
    $this->assertSame(555, $storedOrder->items[1]->product->id);
    $this->assertSame('プリン', $storedOrder->items[1]->product->name);
    $this->assertSame(1, $storedOrder->items[1]->quantity);
    $this->assertSame(778899, $storedOrder->items[2]->product->id);
    $this->assertSame('高級アイス', $storedOrder->items[2]->product->name);
    $this->assertSame(99999, $storedOrder->items[2]->quantity);
}

注文のリポジトリモックは今のところこの程度で良さそうです。

<?pnp
private function createOrderRepository(): OrderRepository
{
    return new class() implements OrderRepository
    {
        private array $records = [];
        private int $id = 0;

        public function save(OrderRecord $record): void
        {
            $this->records[++$this->id] = $record;
        }

        public function findById(int $id): OrderRecord
        {
            return $this->records[$id];
        }
    };
}

商品クエリはこうです。ArrayIterator便利!

<?pnp
private function createProductQuery(): ProductQuery
{
    return new class() implements ProductQuery
    {
        public function filterByIds(array $ids): ProductQuery
        {
            return $this;
        }

        public function paginate(int $perPage, int $currentPage): ProductQuery
        {
            return $this;
        }

        public function execute(): ProductPaginator
        {
            $items = [
                new Product(
                    1234,
                    'ポテチ',
                    130,
                ),
                new Product(
                    555,
                    'プリン',
                    100,
                ),
                new Product(
                    778899,
                    '高級アイス',
                    350,
                ),
            ];
            return new class($items) extends ArrayIterator implements ProductPaginator
            {
                public function __construct(private readonly array $items)
                {
                    parent::__construct($items);
                }

                public function current(): Product
                {
                    return parent::current();
                }

                public function total(): int
                {
                    return count($this->items);
                }

                public function perPage(): int
                {
                    return 100;
                }

                public function currentPage(): int
                {
                    return 1;
                }

                public function getById(int $id): Product
                {
                    foreach ($this as $product) {
                        if ($product->id === $id) {
                            return $product;
                        }
                    }
                    throw new RuntimeException('not found');
                }
            };
        }
    };
}

実行してみましょう。

docker run \
  --volume `pwd`:`pwd` \
  --workdir `pwd` \
  php:8.1.13-zts-alpine3.17 \
  php vendor/bin/phpunit

PHPUnit 9.5.26 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 00:00.004, Memory: 6.00 MB

OK (1 test, 9 assertions)

うまくいきましたね。続けて異常系のテストケースも追加しましょう。

<?pnp
public function testCannotOrderWithoutItems(): void
{
    // 検証内容設定
    $this->expectException(InvalidArgumentException::class);
    $this->expectErrorMessage('Cannot order without items');

    // 前準備
    $orderRepository = $this->createOrderRepository();
    $interactor = new Interactor(
        $orderRepository,
        $this->createProductQuery(),
    );

    // 実行
    $interactor->execute(Input::fromArray([
        'items' => [],
        'user_id' => 1,
    ]));
}

public function testCannotOrderWithLargeItem(): void
{
    // 検証内容設定
    $this->expectException(InvalidArgumentException::class);
    $this->expectErrorMessage('The item quantity must not have more than 1000000.');

    // 前準備
    $orderRepository = $this->createOrderRepository();
    $interactor = new Interactor(
        $orderRepository,
        $this->createProductQuery(),
    );

    // 実行
    $interactor->execute(Input::fromArray([
        'items' => [
            [
                'product_id' => 1234,
                'quantity' => 3000000,
            ],
        ],
        'user_id' => 1,
    ]));
}
PHPUnit 9.5.26 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 00:00.003, Memory: 6.00 MB

OK (3 tests, 13 assertions)

よさそうです。まだ異常系は残っていますが、今回はここまでにします。

終わりに

いかがでしたでしょうか。DDDと実務の距離が少しは縮まったでしょうか。
わたし自身もまだまだ勉強中なので引き続き弊社テックブログの方で進めていければと思います。
tech.trustbank.co.jp

次々回くらいにはLaravelアプリケーション化も行えると良いのですが。。

トラストバンクでは一緒に活躍いただけるエンジニアを募集中です!
www.wantedly.com