【.NET5】僕が有給を犠牲に解釈した何とも言えないDDD+CQRS実装サンプル
読まなくていいあらすじ
DDDだ!CQRSだ!という主張は多く見るけど、自分のやりたいことに即した実装サンプルがなかなか見つからなかった
僕はDDDというか、Repositoryパターンを組み込んだDDDにCQRSを組み込みたかった
コマンド・クエリ間でデータソースは共通のものとし、イベントソーシングは不要
実装例があったとしても、Repositoryパターンがないとか、イベントソーシングの話がメインだったりとか、DTO(ドメインオブジェクトをその外に露出させないようにするための射影したようなオブジェクト)の使い方が好きじゃなかったりとか
はじめに
読者想定
DDDだ!CQRSだ!という主張は多く見るけど、自分のやりたいことに即した実装サンプルがなかなか見つからなかった
という感じで、DDDとCQRSの概念をざっくり理解している人
言い訳
色々間違ってそうな気がするので何かあったら教えてください
実案件で作ったようなものではないのであくまで参考程度に...
全体的に参考にしたもの
https://nrslib.com/bottomup-ddd/
https://www.amazon.co.jp/gp/product/479815072X
https://little-hands.booth.pm/items/1835632
https://github.com/little-hands/ddd-q-and-a/
https://qiita.com/wasimaru/items/5ad064aac7fc6f929cd1
https://omdwn.hatenablog.com/entry/2021/07/09/221616
逆に言うと、以下はまだ読んでいない(恥ずかしい限り。いつか読みます)
https://www.amazon.co.jp/dp/B00GRKD6XU/
https://www.amazon.co.jp/dp/B00UX9VJGW/
つくるもの(つくるドメイン)
図書館の蔵書管理的なものをつくる
ドメイン
色々端折った図の通り
- Books: 本(の情報)。実際はauthor_idとかを持たしていくことになると思う
- BookStocks: 本の在庫。BooksとBookStocksは1:n
- Users: ユーザ。UsersとBookStocksは1:n
ユーザは本(Books)を借りるというか、在庫(BookStocks)を借りるというようなイメージ
テーブル
テーブルに値を入れるとこういう感じ
Users
id | first_name | family_name |
---|---|---|
001 | John | Petrucci |
002 | Jordan | Rudess |
003 | Mike | Portnoy |
004 | Tony | Levin |
Books
id | name |
---|---|
978-0008322069 | 1984 Nineteen Eighty-Four |
978-0141033006 | The Day of the Triffids |
BookStocks
id | book_id | rental_user_id |
---|---|---|
1 | 978-0008322069 | 001 |
2 | 978-0141033006 | NULL |
3 | 978-0141033006 | NULL |
4 | 978-0141033006 | NULL |
貸し出し情報のみのトランザクションテーブル的なものが必要かもしれない...
つくったもの
https://github.com/0mmadawn/DDD_CQRS_Sample
アーキテクチャ
ソリューション
ソリューション(sln)的にはこんな感じ
層ごとにプロジェクト(csproj)を分けている
なんとか層っていうのはレイヤードアーキテクチャでいうところの名前
- TryDDD.sln
フォルダ
フォルダはこんな感じ
├─LibraryDomain │ ├─Models │ │ ├─Books │ │ ├─BookStocks │ │ └─Users │ └─Services ├─LibraryApplication │ ├─Commands │ │ ├─Handlers │ │ └─Requests │ └─Queries │ ├─Handlers │ ├─RepositoryIf │ ├─Requests │ └─Results ├─LibraryInfrastructure │ ├─Books │ ├─BookStocks │ ├─DataModel │ ├─Queries │ ├─Shared │ └─Users └─TryDDD
説明を添える
├─LibraryDomain:ドメイン層 │ ├─Models: │ │ ├─Books:ドメインオブジェクトとか仕様オブジェクト + レポジトリやファクトリのIF │ │ └─... │ └─Services ├─LibraryApplication:アプリケーション層 │ ├─Commands:CQRSのC │ │ ├─Handlers:コマンドを呼ぶためのエントリポイントみたいな │ │ └─Requests:コマンド用のパラメータクラス(コマンドオブジェクト) │ └─Queries:CQRSのQ │ ├─Handlers:クエリを呼ぶためのエントリポイントみたいな │ ├─RepositoryIf:クエリ用レポジトリのIF │ ├─Requests:クエリ用のパラメータクラス(コマンドオブジェクト) │ └─Results:クエリの実行結果用クラス(DTOみたいなもの) ├─LibraryInfrastructure:インフラストラクチャ層 │ ├─Books:レポジトリやファクトリの実装 │ ├─DataModel:データモデル。ドメイン層のドメインオブジェクトをデータソースに依存させないための射影的なもの │ └─Queries:クエリ用レポジトリの実装 └─TryDDD:今回のプログラム群をテストで叩く全体のエントリポイント
ここから各層について細かく説明する
けど、概ね言葉が分かる人はもうgithubのレポジトリ見たほうが早いかも
ドメイン層
├─LibraryDomain:ドメイン層 │ ├─Models: │ │ ├─Books:ドメインオブジェクトとか仕様オブジェクト + レポジトリやファクトリのIF │ │ └─... │ └─Services
├─LibraryDomain │ │ LibraryDomain.csproj │ │ │ ├─Models │ │ ├─Books │ │ │ Book.cs │ │ │ BookId.cs │ │ │ BookName.cs │ │ │ IBookRepository.cs │ │ │ │ │ ├─BookStocks │ │ │ BookStock.cs │ │ │ BookStockId.cs │ │ │ IBookStockFactory.cs │ │ │ IBookStockRepository.cs │ │ │ RentalLimitCountSpecification.cs │ │ │ │ │ └─Users │ │ IUserRepository.cs │ │ User.cs │ │ UserId.cs │ │ UserName.cs │ │ │ └─Services │ BookService.cs │ BookStockService.cs │ UserService.cs
ここはDDD的に割と素直な作りで特に言うことがない
ここにはCQRSの話は出てこないしね
ご存知の通りだと思うけどレポジトリやファクトリのIFがいるのは依存性逆転の原則のやつ
ドメインオブジェクトの中にはすごくかっこいい実装があるが、この形容は自画自賛ではない
この人の実装を参考にした
https://takap-tech.com/entry/2021/02/03/232823
内心で集約の切り方がいまいち怪しい気がしている
あと他の集約にIdを持たせたりしたほうがいいのか?とか...
また、申し訳程度に仕様オブジェクト( RentalLimitCountSpecification
)を作ったけど微妙な気がしている
あとほんの少しだけテストを書いたけど、今回大変なので凍結した
├─LibraryDomainTest │ │ LibraryDomainTest.csproj │ │ │ ├─Models │ │ └─Books
アプリケーション層
├─LibraryApplication:アプリケーション層 │ ├─Commands:CQRSのC │ │ ├─Handlers:コマンドを呼ぶためのエントリポイントみたいな │ │ └─Requests:コマンド用のパラメータクラス(コマンドオブジェクト) │ └─Queries:CQRSのQ │ ├─Handlers:クエリを呼ぶためのエントリポイントみたいな │ ├─RepositoryIf:クエリ用レポジトリのIF │ ├─Requests:クエリ用のパラメータクラス(コマンドオブジェクト) │ └─Results:クエリの実行結果用クラス(DTOみたいなもの)
第1階層をコマンドとクエリをフォルダ単位で分けた
第1階層については、コマンドで特に言えることだけど最初に集約単位(例えばBooks)でフォルダを切るか迷った
けど結局コマンドもクエリも集約を跨ぐ事が多々ある気がしてやめた
├─LibraryApplication │ │ LibraryApplication.csproj │ │ │ ├─Commands │ │ ├─Handlers │ │ │ DeleteUserHandler.cs (これ使い忘れた) │ │ │ LendBookHandler.cs │ │ │ RegisterBookHandler.cs │ │ │ RegisterUserHandler.cs │ │ │ │ │ └─Requests │ │ LendBookCommand.cs │ │ RegisterBookCommand.cs │ │ RegisterUserCommand.cs │ │ │ └─Queries │ ├─Handlers │ │ GetAllBooksHandler.cs │ │ GetAllUserHandler.cs │ │ GetUserHandler.cs │ │ │ ├─RepositoryIf │ │ IQueryRepository.cs (本当はクエリ毎にレポジトリ切ったほうがいいかも) │ │ │ ├─Requests │ │ GetUserQuery.cs │ │ │ └─Results │ GetAllBooksResult.cs │ GetAllUsersResult.cs │ GetUserResult.cs │
アプリケーションサービス(≠ドメインサービス)と呼ばれるものをコマンド・クエリのハンドラに分けたような形
厳密には「HandlerにRequestsフォルダのファイル(クエリ用パラメータクラス)を渡す」という作りで、これは上述したMediatRの実装を参考にした
※でも今回は非同期なAPIでもないのでMediatRは使っていない
https://qiita.com/wasimaru/items/5ad064aac7fc6f929cd1
ちょっと分かりにくいかもしれないので、具体的なコードで書くと、こんな感じに呼び出す
// 本当はDIとかする // コマンド var commandHandler = new HogeHandler(hogeRepository); var commandRequest = new HogeCommand(name: "テスト") commandHandler.Handle(commandRequest); // クエリ var queryHandler = new FugaHandler(hogeRepository); var queryRequest = new FugaQuery(id: 1); var result = queryHandler.Handle(queryRequest);
クエリ側にはRepositoryIF(リポジトリのIF)、Resultsがいるが、これがコマンドクエリの役割の違いの最たるもの
コマンド側は基本的に集約単位に存在するリポジトリを使ってCRUDのRead以外を行う
その際のWriteModelはDTO(インフラ層在中)を使う
クエリ側はユースケースに応じて独自のReadModelをResultsフォルダに用意し、独自のリポジトリを使うためここにRepositoryIFを設けている
今回に関して、それぞれの命名については少し難があって、 RegisterBookHandler
はともかく、 GetAllBooksResult
なんかは、そのユースケースが見えてこないのでよくない
例えば GetAllBooksForIndexView
みたいな具体的な名前の方がいい
(と、Greg Youngは言っている)
インフラストラクチャ層
├─LibraryInfrastructure:インフラストラクチャ層 │ ├─Books:レポジトリやファクトリの実装 │ ├─DataModel:データモデル。ドメイン層のドメインオブジェクトをデータソースに依存させないための射影的なもの │ └─Queries:クエリ用レポジトリの実装
├─LibraryInfrastructure │ │ InitSqlite3.cs (今回SQLiteを使うので最初にそのテーブルを作成したり...などを行う) │ │ LibraryInfrastructure.csproj │ │ │ ├─Books │ │ BookRepository.cs │ │ │ ├─BookStocks │ │ BookStockFactory.cs │ │ BookStockRepository.cs │ │ │ ├─DataModel │ │ BookDataModel.cs │ │ BookStockDataModel.cs │ │ UserDataModel.cs │ │ │ ├─Queries │ │ QueryRepository.cs (本当はクエリ毎にレポジトリ切ったほうがいいかも) │ │ │ ├─Shared │ │ RepositoryBase.cs (各レポジトリの基底クラス。コネクションを管理してDisposeするための実装。実装微妙かも) │ │ │ └─Users │ UserRepository.cs
DataModelは
と書いたけど、今回分かりやすくいうとDapperでテーブルと1:1で結びつけるためのオブジェクト
※非DDD文脈ではEntityと呼ばれることが多い気がする
リポジトリやファクトリは集約単位(Books, BookStocks, Users)のフォルダの中にいて、複雑なRead、つまりクエリの処理がQueriesのQueryRepository.csにまとめられている
ユーザインタフェース(プレゼンテーション層)
これらのドメインを利用する層
.NET MVCでいうとControllerとかその辺
今回はコンソールアプリケーションなので薄い
└─TryDDD │ Program.cs │ TryDDD.csproj
特に言うことはないけど、一応DIをしている
上述したリポジトリのコネクションのDispose周り(IDisposable)はDIに任せておけばOKとのことなのでそれに従った
果たして本当にDisposeされるのか確認していないので何かあったらごめんなさい
利用イメージ
ユーザインタフェース(プレゼンテーション層)の実装を見るのが一番早い
「つくるもの(つくるドメイン)」の「テーブル」で書いた状態を作るような作業をしている
https://github.com/0mmadawn/DDD_CQRS_Sample/blob/main/TryDDD/Program.cs#L74
おわりに
という感じに作ってみたけど正しいのか正しくないのかわからない
個人的にはこの辺はかなり怪しい
一番入り口のとこじゃん!という気もするけど...
内心で集約の切り方がいまいち怪しい気がしている
あと他の集約にIdを持たせたりしたほうがいいのか?とか...また、申し訳程度に仕様オブジェクト(
RentalLimitCountSpecification
)を作ったけど微妙な気がしている
あとテスト書けてないのでその時に破綻している箇所が出てくるかも
新規にIF作ったりすることになりそう
アクセサももっと工夫できるんじゃないかとか
とはいえ一番実装例がなくて困っていたCQRS周りの分割の実装はそこそこどうにかなったかも
会社に行っていない期間にせっせと書いたけど、この手の話は人と会話しないと作れないように思った
転職して次の会社でこの手の話が出来たらもうちょっとどうにかしたい