🐷

イベントソーシングやDDDにおける『集約』の「型が変わる」ことによって起こる柔軟性

2024/10/08に公開

株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。イベントソーシング・CQRSのフレームワーク、Sekibanを現在会社で作っています。オープンソースのフレームワークですのでぜひStarだけでもつけてくださるととても喜びます!

https://github.com/J-Tech-Japan/Sekiban

集約とは

イベントソーシングやドメイン駆動設計(DDD)における集約(Aggregate)は、ドメインのビジネスルールを守るための一貫性の境界を提供する概念です。集約は、複数のエンティティや値オブジェクトをまとめ、一つのまとまりとして取り扱います。

集約のポイント

  1. 一貫性の境界
    集約は、ビジネスルールや整合性を保つために重要な範囲を定義します。特定の操作を行う際、集約内での変更はその範囲内で整合性が保たれるように設計されています。つまり、集約内のデータは常に一貫している必要があるため、集約外のエンティティには影響を与えません。

  2. ルートエンティティ(Aggregate Root)
    集約の中心となるエンティティで、集約全体へのアクセスや操作はこのルートエンティティを介して行います。これにより、集約内部のエンティティへの直接の操作が防止され、集約の整合性が保たれます。

  3. 内部の詳細は外部から隠蔽
    集約内部のエンティティや値オブジェクトは、ルートエンティティを通してしか操作できません。これにより、内部の構造が外部から隠蔽され、外部が内部の構造に依存しないようにします。

  4. 変更の単位
    イベントソーシングでは、集約が変更されたときにイベントが発生します。このイベントは、集約が行った重要なビジネス上の変更を記録するものであり、集約単位での変更履歴が管理されます。

例えば、「注文」というドメインでは、注文そのものが集約となり、その内部に「注文項目(エンティティ)」や「合計金額(値オブジェクト)」などが含まれます。注文に関連する変更はルートエンティティ(注文)を介してのみ行われ、内部のエンティティや値オブジェクトに直接アクセスして変更することはできません。これにより、注文全体の一貫性を保ちながら処理が行われます。

集約の意義

  • ビジネスルールの一貫性:複雑なドメインロジックにおいて、ビジネスルールが常に守られるようにする。
  • トランザクションの範囲を明確化:集約の範囲を超えて一貫性を保つ必要がある場合、別の集約が必要となります。
  • 柔軟性:ドメインが複雑になっても、集約単位での整理により設計をシンプルに保てます。

このように、集約はDDDやイベントソーシングにおいて重要な役割を果たし、ドメインモデルを堅牢かつ一貫性のあるものにするための柱の一つです。

集約の型が変わるとはどういうことか

ドメイン駆動設計(DDD)やイベントソーシングにおいて、集約の型が変わるという概念は、集約のライフサイクルに応じて、その振る舞いや許される操作が変わることを意味します。これにより、特定のビジネスルールや操作が守られるように型で制約を課し、誤操作を防ぐことができます。

例えば、ショッピングカートを考えてみましょう。通常、商品を追加する際は、カートが「商品選択中」の状態でなければなりません。しかし、注文が確定した後や支払いが完了した後に商品が追加できると、問題が発生する可能性があります。たとえば、支払いが済んでいない商品を誤って発送してしまうリスクがあるため、状態に応じてカートの振る舞いを制御する必要があります。

フラグ管理 vs 型による制約

  • フラグの使用
    集約内にフラグを追加して、現在の状態(例:注文確定、支払い済みなど)を追跡する方法は一般的ですが、誤ってそのフラグを見逃したり、ロジックを間違えるリスクがあります。

  • 型による制約
    型を使って状態に応じた操作を明確に制限することは、ミスを防ぐためのより堅牢な方法です。各状態に応じて異なる型を使用することで、誤った操作をコンパイル時に防ぐことができ、予期しない問題を減らせます。

集約の型が変わる具体例

ショッピングカートの状態ごとに、異なる型を持たせることで、特定の操作を制限することができます。

  • 商品選択中カート

    • 可能な操作: 商品追加、削除、支払い情報入力、注文確定
    • 状態: 顧客が商品を選び、カートに入れている段階。
  • 注文確定後カート

    • 可能な操作: 在庫確認、注文内容の最終確認
    • 状態: 顧客の注文が確定し、在庫などから注文が確定している。
  • 支払い済み送信待ちカート

    • 可能な操作: 送信準備、発送会社との連携
    • 状態: 顧客が支払いを終え、商品の発送を待っている状態。
  • 送信済みカート

    • 可能な操作: 発送通知、顧客への追跡情報提供
    • 状態: 商品が発送され、顧客へ通知済み。
  • 受取済みカート

    • 可能な操作: レビュー受付、返金対応
    • 状態: 顧客が商品を受け取り、アフターサービスが可能な段階。

型による一貫性の担保

集約の型を変化させることで、操作が状態ごとに限定されます。例えば、カートが「注文確定後カート」に変わった後は、商品追加機能が完全に無効になります。これにより、誤って商品が追加されることを防ぎ、一貫性が保たれます。

このように、状態ごとに集約の型を変化させることで、システム全体の一貫性と安全性を確保しつつ、ビジネスルールをより自然な形で実現することが可能です。

関数型プログラミングによって生じる柔軟性

関数型プログラミングを活用すると、集約の状態管理に新たな柔軟性が生まれます。特に、イミュータブルなデータモデルを使うことが多く、イベントによって集約の状態を作り上げるというアプローチが自然に採用されます。これにより、状態遷移やイベントの適用を関数を通じてシンプルに記述できるのが特徴です。

関数型の集約の状態遷移

関数型では、状態の変化は以下のように表現されます:

Aggregate' = EventType.OnEvent(Aggregate, event)

このように、OnEventという関数を通じて、イベントに基づいて新しい集約の状態(Aggregate')が作られます。重要なのは、新しい集約(Aggregate')は元の集約(Aggregate)と同じ型にする必要がないという点です。これにより、集約が状態に応じて異なる振る舞いや構造を持つことが可能になります。

オブジェクト指向との違い

もちろん、オブジェクト指向プログラミングでもこの考え方を取り入れることは可能ですが、関数型プログラミングではよりシンプルかつ直感的に実装できます。関数型では状態を変化させるロジックが副作用のない関数として定義されるため、集約の状態管理が明確になり、コードの可読性や保守性が向上します。

コマンドの適用と型の制約

前の部分で述べたように、集約の型が変わることで特定の機能が制限される仕組みを実現する際、関数型のアプローチは非常に有効です。特定のコマンド(操作)が実行できるのは、特定の状態を持つ集約に対してのみとなり、状態遷移に伴って自動的に適用できる機能が変わります。

例えば、商品選択中カート注文確定後カートでは許可される操作が異なります。これを関数型のスタイルで表現する場合、コマンドごとに適用する集約の型を制限することができます:

  • 商品追加商品選択中カートにのみ適用可能
  • 注文確定商品選択中カートの終了後に発生するイベントで、型が注文確定後カートに変わる

このように、コマンドと状態の関係を型で明確に定義することにより、誤った操作が防がれ、一貫したビジネスルールを簡単に実現できるのです。

関数型プログラミングでは、集約の型や状態がイベントによって自然に変化し、それに応じて操作を制限する柔軟性を持たせることができます。これにより、ドメインロジックの管理がシンプルになり、システム全体の整合性を保ちながら柔軟な設計が可能となります。

関数型イベントソーシングの流行

実はこのタイプのサブタイプは、いくつかのイベントソーシングライブラリではすでに実現しています。Sekibanでも、Subtype機能がサポートされています。

https://github.com/J-Tech-Japan/Sekiban/tree/main/internalUsages/FeatureCheck.Domain/Aggregates/SubTypes/RecordBaseTypes/Subtypes

また、有名なイベントソーシングライブラリ、Axonでもサポートされています。

https://docs.axoniq.io/axon-framework-reference/4.10/axon-framework-commands/modeling/aggregate-polymorphism/

Axon Framework 4.10のAggregate Polymorphism(集約ポリモーフィズム)は、複数の集約が共通の基底クラスを持つことで再利用性と拡張性を高める仕組みです。基底クラスに共通のビジネスロジックを実装し、各サブクラスが異なる振る舞いを持つ設計が可能です。

Axonは、コマンドを受信すると自動で適切な集約サブクラスにルーティングし、正しいコマンドハンドリングを保証します。これにより、柔軟で管理しやすい集約設計が実現できます。

また、イベントソーシング経験の長いOscar Dudyczさんも以下の記事でこのように書いています。

https://event-driven.io/en/my_journey_from_aggregates/

各状態は実際には異なる集約です。データは似ているが、動作は異なり、同じです。例はありますか? もちろん、Booking.comの部屋予約について考えると、それを単一の集約としてモデル化できますが、実際には、開始された予約、確認された予約、完了した予約に対して実行できる操作の明確なセットがあります。また、異なるデータもあります。ショッピングカート、注文などでも同じことがわかります。統一された古典的なC#/Javaバージョンは、ますます時代遅れになりました。また、近年はTypeScriptで多くの作業を行い、代数型を使用したコーディングについて別の視点を得ました。

このようにSekibanもサブタイプをサポートしているのですが、色々集約を同僚と設計している時に、サブタイプだけでは満足できないと感じました。

サブタイプか、Traitか

集約が状態遷移に伴って型が変わる仕組みを考えると、興味深い選択肢としてサブタイプTraitのアプローチがあります。ここでは、これら2つの考え方を比較しながら、どのようにして集約の型が柔軟に変わるのかを解説します。

サブタイプとTraitの違い

サブタイプは、あるクラスが親クラスを継承し、親クラスのすべての機能を受け継ぐ設計方法です。一方で、Trait(もしくはC#ではインターフェース)は、複数の型に共通する振る舞いを定義し、それを実装することでクラスがその機能を持つようにします。

サブタイプのアプローチ

サブタイプを使用する場合、状態ごとに異なる集約型を定義し、親クラスから継承することで共通の機能を引き継ぎます。これは、共通の動作を持つ状態であれば有効ですが、すべての集約が同じ親クラスを持たなければならないため、柔軟性に欠ける場合があります。

Traitのアプローチ

Trait(またはインターフェース)のアプローチでは、集約の状態が変わるたびに異なるTraitを実装し、それぞれの状態に応じた機能を実装します。重要なのは、型ごとに共通の親型を持つ必要がないため、状態ごとに必要なTraitだけを実装できる点です。

具体例: ショッピングカートの集約

ここで、先ほどのショッピングカートの例を使って説明します。

サブタイプを使う場合

例えば、カートの状態が「商品選択中」から「注文確定後」に変わるとき、サブタイプを使用すると、次のような継承階層を考えることができます。

  • BaseCart(親クラス)
    • SelectableCart(商品選択中カート)
    • ConfirmedCart(注文確定後カート)

この方法では、BaseCartに共通の処理を記述し、SelectableCartConfirmedCartにそれぞれ固有の処理を実装します。ですが、この場合、すべてのカートがBaseCartから派生する必要があるため、継承関係が固定化されやすくなります。

Trait(インターフェース)を使う場合

一方、Traitのアプローチでは、各カートの状態に応じて必要なTrait(インターフェース)を実装する形にします。例えば、次のように定義できます。

  • ICart(共通のインターフェース)
    • ISelectable(商品選択中のカートが実装するTrait)
    • IConfirmable(注文確定後のカートが実装するTrait)
    • IPayable(支払い可能な状態のカートが実装するTrait)

これにより、カートの状態が「商品選択中」から「注文確定後」に変わるとき、SelectableCartからConfirmedCartへと型が変わりますが、それぞれの状態に応じたTraitのみを実装します。この場合、親型に依存する必要がなく、状態ごとに柔軟に振る舞いを定義できます。

C#におけるTraitの実現

SekibanのようにC#を使用している場合、C#には直接Traitという概念は存在しませんが、インターフェースを用いてこの概念を実現することができます。C#のインターフェースは、Traitのように共通の振る舞いを定義し、各集約がそれを実装することで柔軟な状態遷移を可能にします。

サブタイプのアプローチは継承関係に基づき、親型から共通の振る舞いを受け継ぐため、シンプルな場合には有効ですが、複雑な状態遷移や多様な振る舞いが必要な場合には柔軟性に欠けることがあります。一方、Trait(インターフェース)を使うと、型の制約を緩め、集約の状態ごとに異なる振る舞いを実装できるため、状態遷移に応じて柔軟な設計が可能になります。

Sekibanで実現したいこと

Subtypeを用いた形はすでにSekibanで実現していますが、上記で書いたもっと柔軟なTraitスタイルのイベントソーシングの機能をSekibanに追加することができればと考えています。また上記の細かな仕様については今考えているところで、いままでの集約種別に紐づいて作ったデータに関しては、集約種別内の全データを取得して集約種別ないのプロジェクションをすることが可能だったのですが、この形だとそれが難しくなると考えています。

色々なフレームワークを調べてみたのですが、上記のようなTraitスタイルの機能を含んだライブラリ、フレームワークはあまりみられませんでした。イベントソーシング経験者の皆さん、よかったらご意見いただけないでしょうか?Event Storeなどは、Aggregateの型に依存しないイベントストリームという概念があるのですが、ステートする時の一貫性まで考えて型の遷移、またその機能をまとめているという感じではないように感じます。

Eventuous の関数サービス

https://eventuous.dev/docs/application/func-service/

Martenのイベントハンドラー記述方式
https://wolverinefx.net/guide/durability/marten/event-sourcing.html

EquinoxのF#イベントソーシング
https://github.com/jet/equinox

Elixir によるCommanded
https://github.com/commanded/commanded/blob/master/guides/Aggregates.md#example-aggregate

など、関数型を取り込んだものはたくさんあるのですが、型遷移まで柔軟に行なっている概念についてよく説明されているものがなかなかない気がしています。皆様で型で制限しつつ、サブタイプ型ではないイベントソーシングの集約についての記事や実装例をご存知の方、ぜひ教えていただけると嬉しいです!

ジェイテックジャパンブログ

Discussion