Rustで試すCQRS+ES

やりたいこと
以下を参考に Rust で CQRS + ES の実装してみる.
利用予定技術
- プログラム言語: Rust
- デプロイ先: AWS
- プログラムの実行基盤: AWS Lambda
-
IaC: AWS CDK or Terrarorm
- 悩み中。新しいこと学びたいなら Terrarorm だし, 今のスキルを伸ばしたいなら CDK.
- DB: Amazon DynamoDB

参考になりそうなリンク

CQRS の前にまず AWS Lambda のデプロイを簡単にできるようにしたい。
ということで cargo-lambda, cargo-lambda-cdk を導入してみる。
cargo-lambda は作成時にいろんなテンプレートが用意されているみたい。すごい。

とりあえずAWS Lambda のデプロイをできる仕組みまで整えた。
Rust で Lambda を実装する例として aws-lambda-rust-runtime
の example がめっちゃ参考になった。
axum を使った例もある。しかもCDK付きで。
lambda_runtime も axum と同様に Tower を使っているので併用が容易っぽい。

プロジェクト管理ドメインを対象に実装することにした。
GitHub Copilot にドメイン分析を手伝ってもらった。

ドメイン分析時に、「集約」についてうまく言語化できなかったので Copilot に整理してもらった
ドメイン駆動設計(DDD)における集約(Aggregate)の概念解説
1. 集約の基本概念
定義
集約(Aggregate)とは、関連するオブジェクト(エンティティと値オブジェクト)のクラスターであり、不変条件によって結合され、一つの単位として扱われるものです。集約はDDDの戦術的設計パターンの中核的な概念で、複雑なドメインモデルを管理可能な単位に分割するために重要です。
目的
集約の主な目的は以下の通りです:
- ドメインの複雑さを管理可能な境界で分割する
- データの整合性を確保する
- ビジネスルールの実装場所を明確にする
- トランザクション境界を定義する
- 並行処理における競合を減らす
特性
- 不変条件の維持: 集約内部の全てのオブジェクトは、常に一貫した状態を保つ必要があります
- トランザクション境界: 集約は一つのトランザクションで更新される最小単位です
- カプセル化: 集約は内部の複雑さを隠蔽し、外部にはシンプルなインターフェースを提供します
- 整合性の単位: 集約内のすべてのオブジェクトは強整合性が保証されます
- 永続化の単位: 多くの場合、集約は保存・読み込みの単位となります
2. 集約ルート(Aggregate Root)
役割
集約ルートは以下の責任を持ちます:
- 集約全体への唯一のアクセスポイントとなる
- 集約内部の不変条件を維持・検証する
- 集約内のオブジェクト間の関係を管理する
- 集約の状態変更操作を提供する
- ドメインイベントの発行を担当する(イベントソーシングを使用する場合)
アクセス制御
集約ルートは以下の方法で境界を強制します:
- 集約内の他のエンティティや値オブジェクトへの直接アクセスを禁止する
- すべての操作が集約ルートを経由するよう設計する
- 集約内の他のオブジェクトは外部から直接参照されないようにする
- 集約の内部構造を隠蔽し、必要なインターフェースのみを公開する
識別
- 集約ルートは通常、グローバルに一意な識別子(ID)を持ちます
- この識別子は集約全体を識別するために使用されます
- リポジトリは集約ルートのIDを使って集約全体を取得・保存します
- 外部からの参照はすべて集約ルートのIDを通じて行われます
3. 集約の境界
決定要因
集約の境界を決定する主な要因:
- ビジネスルールと不変条件: 同じ不変条件で管理される必要があるオブジェクトは同じ集約内に配置
- トランザクションの一貫性要件: 同時に更新される必要があるオブジェクトは同じ集約に
- ライフサイクルの結合: ライフサイクルが強く結合しているオブジェクトは同じ集約に
- パフォーマンス考慮: 頻繁に一緒に取得・更新されるオブジェクトは同じ集約に
- スケーラビリティ要件: 並行処理の競合を減らすため、適切なサイズに保つ
サイズ
- 原則としては小さく: Eric Evansは「可能な限り小さく保つ」ことを推奨
- 過度に大きい集約の問題: パフォーマンス低下、並行処理の競合増加、複雑性の増大
- 過度に小さい集約の問題: トランザクション境界が細かすぎて整合性維持が困難
- 適切なサイズ: ドメインの整合性要件とパフォーマンス要件のバランスを取る
トランザクション
- 集約は単一のトランザクションで更新される最小の単位
- 一つのコマンドで複数の集約を更新することは避けるべき
- 複数の集約にまたがる操作は、ドメインイベントやSagaパターンを使用
- 集約の境界はトランザクションの境界と一致させることで、並行性の問題を軽減
4. 集約とエンティティ/値オブジェクトの関係
構成
集約は以下のように構成されます:
- 一つの集約ルート: 集約全体への唯一のアクセスポイント
- 複数のエンティティ: 独自のIDを持ち、集約内部でのみ意味を持つ
- 複数の値オブジェクト: 不変で識別子を持たず、属性によって定義される
- 集約内のオブジェクト間の関係: 親子関係、コンポジション関係など
ID管理
-
集約ID: 集約ルートのIDであり、グローバルに参照可能
- リポジトリはこのIDを使って集約全体を取得
- 他の集約からの参照もこのIDを使用
- ドメイン内でユニークな意味を持つことが多い
-
エンティティID: 集約内の個々のエンティティを識別するためのID
- 集約内部でのみ意味を持つ(ローカルID)
- 外部からは集約ルートのIDとの組み合わせでしか意味を持たない
- 集約の境界内でユニークであれば十分
入れ子構造
集約の中に別の集約を配置しない理由:
- 集約が必要以上に大きく複雑になり、管理が困難になる
- 不変条件の適用範囲が広がりすぎて整合性維持が難しくなる
- 並行処理時の競合が増加し、パフォーマンスが低下する
- 責任の境界が曖昧になり、単一責任の原則に反する
5. 集約間の関係
参照方法
- ID参照の原則: 他の集約は直接参照せず、IDによる間接参照を使用
// 良い例:ID参照
class Order {
private CustomerId customerId; // IDのみを保持
// ...
}
// 避けるべき例:直接参照
class Order {
private Customer customer; // 集約を直接参照
// ...
}
- リポジトリを介した取得: 必要なときにIDを使ってリポジトリから集約を取得
- 遅延読み込み: 必要になった時点で参照先の集約を読み込む
- プロキシパターン: 参照が必要になった時点で自動的に読み込む仕組み
整合性
集約間の整合性維持方法:
- 最終的整合性: 集約間は即時の強整合性ではなく、最終的な整合性を目指す
- ドメインイベント: 集約の変更時にイベントを発行し、他の集約に伝播
- Sagaパターン: 長期トランザクションを複数のステップに分割し、各ステップで一つの集約を更新
- バウンデッドコンテキスト: 異なるコンテキスト間の変換と統合を通じて整合性を維持
結合度
- 低結合: 集約間は低い結合度を保ち、独立して変更・進化できるようにする
- コンテキストマップ: 異なる集約間の関係を明示的にマッピングする
- 共有カーネル: 複数のバウンデッドコンテキスト間で共有される概念を明確に定義
- 顧客/サプライヤ関係: 集約間の依存関係を明確にし、変更の影響を最小限に抑える
6. 集約の設計パターンと実践
一般的パターン
- ルートエンティティパターン: 集約の責任を集約ルートに集中させる
- ファクトリメソッド: 集約の生成を専用のファクトリメソッドにカプセル化
- 仕様パターン: 集約の検証ロジックを分離して再利用可能にする
- イベントソーシング: 集約の状態変更をイベントの連続として記録
- コマンドパターン: 集約への操作をコマンドオブジェクトとしてカプセル化
リファクタリング
既存の集約をリファクタリングする際の考慮事項:
- 責任の明確化: 各集約の責任を明確にし、単一責任の原則に従う
- サイズの適正化: 大きすぎる集約を分割、小さすぎる集約を統合
- 境界の再検討: ビジネスルールの変化に応じて集約の境界を再評価
- 整合性の維持: リファクタリング中も不変条件を維持する
- テストの重要性: 集約の振る舞いをテストで保証する
性能トレードオフ
- 読み取り最適化: 読み取りモデルと書き込みモデルを分離(CQRS)
- 遅延読み込みvs事前読み込み: 集約の読み込み戦略の選択
- 集約サイズとスケーラビリティ: 小さな集約は並行性が向上するが、複雑な操作が難しくなる
- キャッシング戦略: 頻繁にアクセスされる集約のキャッシュ
- 永続化最適化: 集約の保存方法(ドキュメントDB vs リレーショナルDB)
7. 一般的な誤解と落とし穴
大きすぎる集約
大きすぎる集約の問題点:
- メモリ使用量の増加とパフォーマンスの低下
- 同時実行時の競合が増加し、スケーラビリティが低下
- 集約の責任が不明確になり、コードの複雑性が増す
- テストが困難になる
- 変更の影響範囲が広くなり、メンテナンスが困難に
小さすぎる集約
小さすぎる集約の問題点:
- 集約間のトランザクション整合性の維持が困難に
- ビジネスルールが集約間に分散し、理解が難しくなる
- 関連するオブジェクト間の整合性を維持するためのコードが複雑化
- 複数の集約にまたがる操作が増え、設計が複雑になる
- パフォーマンスが低下する可能性(多数の集約をロードする必要)
集約の誤用
一般的な誤用や誤解:
- データベーステーブルに基づいて集約を定義する(技術駆動設計)
- ビジネスルールや不変条件を無視した集約設計
- 集約を単なるデータ構造として扱い、振る舞いを含めない
- 集約間の関係を直接参照で実装
- 単一のトランザクションで複数の集約を更新
- 集約の境界を明確に定義せず、徐々に拡大させる
8. 実践的な例
代表的な例
ECサイトの注文集約
注文(Order)- 集約ルート
├── 注文ID(OrderId)- 値オブジェクト
├── 注文状態(OrderStatus)- 値オブジェクト
├── 顧客ID(CustomerId)- 値オブジェクト(他の集約への参照)
├── 配送先住所(ShippingAddress)- 値オブジェクト
├── 注文日時(OrderDate)- 値オブジェクト
└── 注文明細リスト(OrderItems)- エンティティのコレクション
├── 注文明細(OrderItem)- エンティティ
│ ├── 商品ID(ProductId)- 値オブジェクト(他の集約への参照)
│ ├── 数量(Quantity)- 値オブジェクト
│ └── 単価(Price)- 値オブジェクト
└── ...
銀行口座集約
口座(Account)- 集約ルート
├── 口座ID(AccountId)- 値オブジェクト
├── 口座種別(AccountType)- 値オブジェクト
├── 口座状態(AccountStatus)- 値オブジェクト
├── 所有者ID(OwnerId)- 値オブジェクト(他の集約への参照)
├── 残高(Balance)- 値オブジェクト
└── 取引履歴(Transactions)- エンティティのコレクション
├── 取引(Transaction)- エンティティ
│ ├── 取引ID(TransactionId)- 値オブジェクト
│ ├── 取引種別(TransactionType)- 値オブジェクト
│ ├── 金額(Amount)- 値オブジェクト
│ └── 取引日時(TransactionDate)- 値オブジェクト
└── ...

やったこと
以下実施した
- Write API 側について一部機能の実装
- AWS 側のリソース準備
- AWS Lambda Function (Write API 関数実行環境)
- Amazon Cloudfront distribution (CDN)
- Amazon DynamoDB (Event Store)
学んだこと
- Cargo の workspace 機能を使った開発
- GraphQL
- GraphQL のエンドポイント実装方法や呼び出しの方法
- axum を用いたルータの定義方法
- axum をLambda Function で実行する方法
- tower によるミドルウエア定義
- Cloudfront の origin に Lambda 関数URLを指定した際, POST, PUT メソッドについてはリクエストボディのハッシュ値を HTTP ヘッダに加える必要があること
- Lambda@Edge のデプロイ方法