アーキテクチャ ·

集約の境界をどこで切るか — トランザクションの範囲から考える

  • DDD
  • 集約
  • トランザクション

この記事で扱う問題

楽観ロックの記事では、Order を1つの単位として読み、version を確かめて1つの単位として保存しました。では、その「1つの単位」には何まで入れるべきでしょうか。

ここを決めずに進めると、集約は2つの方向に崩れます。

大きすぎる場合。例えば、Customer が自分の注文の一覧を持ち、Order が明細ごとに Product(価格や在庫を持つ商品のオブジェクト)を持つ設計です。オブジェクトのつながりとしては自然に見えます。ただ、

  • 注文を1件確定するだけで、顧客と、その顧客の全注文と、商品を読み込む
  • 同じ顧客の注文どうしは Customer の version を、同じ商品を含む注文どうしは Product の version を奪い合い、関係のない更新どうしが衝突する
  • 悲観ロックを使えば、関係のない処理まで待たされる

小さすぎる場合。逆に、明細を OrderLine という独立した集約にして、専用の Repository で保存する設計です。明細の追加は軽くなりますが、

  • 「確定済みの注文の明細は変えられない」は、注文の状態と明細の両方を見ないと判断できない
  • 「1回の注文の合計は上限まで」は、2つのリクエストが同時に明細を足すと、それぞれは上限以下を確かめたのに、合わせると上限を超える

どちらも、集約の境界を「データのつながり」や「テーブルの単位」で決めたことから起きます。

判断

境界は「コミットの瞬間に一緒に正しくなければならないルール」で決める

私は、集約の境界を次の問いで決めます。

このルールは、コミットした瞬間に必ず成り立っていなければならないか。少し遅れて成り立てばよいか。

コミットの瞬間に成り立たなければならないルールは、1つのトランザクションの中で、関係する状態をまとめて確かめて書く必要があります。楽観ロックの version は集約ごとに1つなので、そのルールに関わる状態は同じ集約に入れる、というのが判断の軸です。少し遅れてよいルールは、集約の外に出し、後から追いつかせます。

注文の例で整理すると、次のようになります。

ルール 一緒に必要な状態 置き場所
確定済みの注文の明細は変えられない 注文の状態・明細 Order の中(明細を含める)
1回の注文の合計は上限まで すべての明細 Order の中
在庫を超えて引き当てない 商品ごとの在庫数 在庫の側(Order の外)
未払いの注文は1人3件まで 顧客ごとの未払いの件数 顧客ごとの件数(Order の外)
確定したら出荷を依頼し、ポイントを付ける — 後から追いつけばよい(キューや Outbox)

集約の中:注文と明細

明細は Order の中に置き、明細の追加は Order を通してしか行えないようにします。明細はそれ単体で取り出したり保存したりしないので、ここでは不変の値として持ちます(Money は Value Object の記事の定義を使います。例外クラスの定義は省略します)。

final readonly class OrderLine
{
    public function __construct(
        public string $sku,
        public int $quantity,
        public Money $unitPrice,   // 注文した時点の単価。商品の価格が後で変わっても、この注文は変わらない
    ) {
        if ($quantity < 1) {
            throw new InvalidOrderLine("数量は1以上である必要があります: {$quantity}");
        }
    }

    public function subtotal(): Money
    {
        return new Money($this->unitPrice->amount * $this->quantity, $this->unitPrice->currency);
    }
}

Order には、明細と、明細に関するルールを足します。既存の項目とメソッドは変えません。

final class Order
{
    /** 例えば、1回の注文の合計は100万円まで。この例では、注文の通貨を円に固定する */
    private const MAX_TOTAL_AMOUNT = 1_000_000;

    // id・status・confirmedAt・confirmedBy・version と、
    // place()・reconstitute()・id()・version()・confirm()・cancel() は既存の定義のまま(省略)。
    // 保存済みの明細は reconstitute() で受け取り、合計の上限もそこで確かめる(省略)

    /** @var list<OrderLine> */
    private array $lines = [];

    public function addLine(OrderLine $line): void
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new OrderCannotBeModified($this->id, $this->status);
        }

        $total = $this->total()->add($line->subtotal());   // 円以外の明細なら、Money が例外を投げる

        if ($total->amount > self::MAX_TOTAL_AMOUNT) {
            throw new OrderTotalExceeded($this->id, $total);
        }

        $this->lines[] = $line;
    }

    /** @return list<OrderLine> */
    public function lines(): array
    {
        return $this->lines;
    }

    public function total(): Money
    {
        return array_reduce(
            $this->lines,
            fn (Money $sum, OrderLine $line): Money => $sum->add($line->subtotal()),
            new Money(0, Currency::JPY),
        );
    }
}

明細の追加も、注文の保存として Repository の save() を通ります(OrderMapper が明細の追加・削除も反映する前提です)。save() は明細だけが変わったときも注文の version を進めるので、冒頭の「2つのリクエストが同時に明細を足す」場合は、後から保存した方が OrderWasModifiedConcurrently で失敗します。やり直すと、先に足された明細を含めて合計を確かめ直します。明細を別の集約にしていたら、この確認を効かせる場所がありません。

集約の外は、オブジェクトではなく ID で参照する

明細が持つのは Product のオブジェクトではなく、SKU という識別子と、注文した時点の単価です。顧客も同じで、注文に顧客を持たせるなら Customer ではなく顧客の ID を持たせます。

ID で参照すると、Order を読み込んでも商品や顧客は読み込まれず、Order のメソッドから商品の在庫や顧客の状態を書き換える経路もなくなります。「注文を保存したら、なぜか商品も更新されていた」ということが起きません。単価をコピーして持つのは、商品の価格と注文の金額が、別のルールで変わる別の値だからです。商品の価格を変えても、過去の注文の合計が変わってはいけません。

集約をまたぐ更新:同じ DB なら1文で、別の場所なら後から

在庫は Order の外にあります。それでも「確定したのに在庫が引き当てられていない」状態は、利用者に見せたくありません。在庫が同じ DB にあるなら、私は在庫を集約として読み込まずに、楽観ロックの記事の StockAllocator(条件付きの UPDATE で引き当てる)を、注文の確定と同じトランザクションで呼びます。既存の ConfirmOrder に、在庫の引き当てを足すと次のようになります。

final class ConfirmOrder
{
    public function __construct(
        private OrderRepository $orders,
        private StockAllocator $stocks,
        private TransactionManager $transaction,
        private Clock $clock,
        private ShipmentRequestQueue $shipmentRequests,
    ) {
    }

    public function execute(OrderId $id, int $operatorId): ConfirmOrderResult
    {
        return $this->transaction->run(function () use ($id, $operatorId) {
            $order = $this->orders->findOrFail($id);
            $order->confirm($operatorId, $this->clock->now());   // 確定できない状態なら、在庫に触る前にここで止まる

            // 在庫の行をロックする順序をそろえるため、SKU の順に引き当てる
            $lines = $order->lines();
            usort($lines, fn (OrderLine $a, OrderLine $b): int => strcmp($a->sku, $b->sku));

            foreach ($lines as $line) {
                if (! $this->stocks->allocate($line->sku, $line->quantity)) {
                    throw new OutOfStock($id, $line->sku);   // ロールバックされ、先に引き当てた分も戻る
                }
            }

            $this->orders->save($order);
            $this->shipmentRequests->enqueue($id);

            return ConfirmOrderResult::from($order);
        });
    }
}

1つのトランザクションで2つのものを更新していますが、在庫の側は集約として読み込んでいません。在庫のルール(在庫を超えて引き当てない)は条件付きの UPDATE の1文が守り、Order のルールは Order が守ります。注文の保存が衝突で失敗すれば、引き当てもロールバックされます。

その代わり、引き当てた在庫の行は、コミットまでロックされたままです(MySQL の InnoDB でも PostgreSQL でも、UPDATE で取った行ロックはトランザクションの終わりまで持ちます)。人気の商品では、同じ商品を含む確定がここで順番待ちになります。このトランザクションの中では外部 API を呼ばず、出荷の依頼もコミットの後に出るようにしておきます。在庫を戻す処理(キャンセルなど)も、在庫を先に、注文を後に更新する順にそろえておかないと、互いの行を待ち合ってデッドロックになります。findOrFail() はロックを取らずに読むので、注文の行をロックするのは最後の save() の UPDATE です。一方、楽観ロックの記事で更新が集中する注文に使った findForUpdate() は、読む時点で注文の行をロックします。注文と在庫の両方を更新する処理では findForUpdate() を使わないか、使うならすべての経路で注文を先に、在庫を後にロックする順にそろえます。

なお、この形では「在庫の引き当てが済んでいなければ確定できない」というルールを守っているのは、Order ではなく ConfirmOrder のトランザクションです。ほかの経路が confirm() を呼んで save() すれば、このルールを破れます。そのため、注文の確定は ConfirmOrder に一本化し、ほかの UseCase やバッチから confirm() を直接呼ばないようにします。

在庫が別のサービスや別の DB にあるなら、1つのトランザクションには入りません。そのときは、注文に「引き当て待ち」のような途中の状態を持たせ、引き当てに失敗したら注文を取り消す、という手順として設計することになります。このあたりは Saga と呼ばれる領域で、私自身まだ深めている途中ですが、「途中の状態を利用者にどう見せるか」を先に決めないと設計できない、というのが今のところの考えです。

なぜそう判断したか

「データのまとまり」で切らない理由

ER 図や画面の単位で集約を切ると、「顧客と、その注文」「注文と、その商品」のように、つながっているものがすべて1つにまとまります。つながっていることと、一緒に正しくなければならないことは別です。顧客の住所を変える処理と、その顧客の注文を確定する処理は、同時に動いても互いのルールを壊しません。同じ集約に入れると、壊さないはずの2つの更新が version を奪い合い、片方が失敗します。

集約の境界は「どこまでを1回で正しく保つか」の範囲なので、ルールから決めます。私が状態変更の設計で重視している「誰がこのルールを守る責任を持つのか」を、トランザクションの範囲に引き直したもの、とも言えます。

「1トランザクション1集約」を絶対の規則にしない理由

DDD では、1つのトランザクションで更新する集約は1つにする、という原則がよく挙げられます。私も原則としては従います。ただ、同じ DB の在庫の引き当てまで、キューに回して後から追いつかせると、「確定したのに在庫がない」注文を作り、後から取り消す手順が要ります。利用者から見れば、確定の画面で在庫切れと言われるほうがずっと分かりやすいです。

そこで、相手の集約を読み込まず、条件付きの UPDATE の1文で済む更新に限って、同じトランザクションに入れることを許しています。相手を読み込んで判断し、保存し直す必要があるなら、それは2つの集約を1つのトランザクションで扱っていることになり、衝突もロックも大きくなるので、境界を見直すか、後から追いつかせる設計に切り替えます。

件数の上限のために、大きな集約を作らない理由

「未払いの注文は1人3件まで」というルールを守るために、Customer に注文の一覧を持たせたくなることがあります。Customer が未払いの件数を数えてから注文を足せば、確かにルールは守れます。ただ、その顧客のどの注文を更新しても Customer の version が進むので、支払いの通知と新しい注文が衝突し、顧客の注文が増えるほど読み込みも重くなります。

かといって、注文を作る前に件数を SELECT で数えるだけでは、同時に来た2つの注文がどちらも「2件」を数えて、4件目ができます。私は、件数そのものを顧客ごとの1行に持たせ、在庫と同じく条件付きの UPDATE で増やします。

use Illuminate\Database\ConnectionInterface;

final class DatabaseUnpaidOrderQuota implements UnpaidOrderQuota
{
    private const LIMIT = 3;

    public function __construct(private ConnectionInterface $db)
    {
    }

    /** 枠が残っていれば1つ使って true を、残っていなければ何もせず false を返す。注文の作成と同じトランザクションで呼ぶ */
    public function reserve(int $customerId): bool
    {
        $updated = $this->db->table('customer_unpaid_order_counts')
            ->where('customer_id', $customerId)
            ->where('unpaid_count', '<', self::LIMIT)
            ->increment('unpaid_count');

        return $updated === 1;
    }
}

支払いが済んだとき、または未払いのまま取り消したとき(キャンセルや期限切れ)は、同じく条件付きの UPDATE で1つ減らします(customer_unpaid_order_counts の行は、会員登録のときに作られている前提です)。守りたいルールに必要な状態だけを小さく切り出せば、大きな集約を作らずに済みます。

どこから設計過剰になるか

  • ルールがほとんどない機能:単純な CRUD の管理画面に、集約の境界を設計する必要はありません。テーブルごとに保存すれば足ります
  • 最初から細かく分ける:性能を理由に、ルールを確かめる前から集約を小さく分けると、冒頭の「小さすぎる」問題を自分で作ります。まずルールから境界を決め、衝突やロック待ちが実際に問題になってから分け方を見直します
  • 少し遅れてよいルールを、同じトランザクションに入れる:ポイントの付与、集計、通知のメールは、確定と同時でなくても困りません。同じトランザクションに入れると、それらの失敗が確定の失敗になります。Outbox やキューで後から追いつかせます
  • 画面の表示のために集約を大きくする:注文の詳細画面で商品名や顧客名を出したいからといって、Order に Product や Customer を持たせる必要はありません。表示は、読み取り専用のクエリで必要な列を結合して取れば足ります
  • 明細が非常に多くなる集約:1つの注文に数千の明細が付くような業務では、毎回すべての明細を読み込む設計が重くなります。その場合も、まず「どのルールが明細全体を見ているか」を確かめ、そのルールだけを集計した値(合計金額など)で守れないかを考えます

まとめ

  • 集約の境界は、データのつながりではなく、「コミットの瞬間に一緒に正しくなければならないルール」の範囲で決める
  • 集約の外は ID で参照し、少し遅れてよいルールはキューや Outbox で後から追いつかせる
  • 同じ DB の別の集約は、読み込まずに条件付きの UPDATE の1文で済むときだけ、同じトランザクションに入れる

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。