貧血モデルの向こう側:失われた「意図」の言語
BEAR.Sunday Advent Calendar の続きです。前回の記事「Data Mapperをめぐる二つの世界観」では、MVCフレームワークにおける「モデルの空洞化(貧血モデル)」と、それを補うための「軽量DDD」という名の整理術について議論しました。
今回はその夜の続きです。koriymとたつきちは、居酒屋を出て、駅までの道を歩きながら、議論の続きを始めます。
⚠️ 重要: この議論の二人は実在の人物と一切関係がありません。ご注意ください。
ラウンド5:その整理は、どこまで効くのか
会場を出たあとの夜風は、熱気を帯びた議論のあとには心地よく感じられた。二人は駅へ向かう道すがら、先ほどの「ロジックの行き場」についての話を反芻していた。
たつきち:
「……でも、結局のところ、あれって“置き場所”の問題ですよね。」
koriym:
「というと?」
たつきち:
「MVCのMがデータモデルになって、ロジックが行き場を失った。だからServiceだのUseCaseだのを後付けして、結果として散らかった。だったら、最初から分ければいいんじゃないかって思うんです。読む処理と、書く処理を。 koriymさん、ご存知ですよね。"CQRS"、Qiitaでも時々見ます。」
たつきちは言い切った。整理としては、悪くないはずだという自信がある。
koriym:
「どんな感じにします?」
たつきち:
「読み込み用のRepositoryと、書き込みのRepositoryを分けて、書き込みはCommandとして投げる感じです。場合によってはCommand Busを挟んで……」
一瞬、言葉を選ぶ。
たつきち:
「正直、あれ、結構面倒ですけど。」
koriym:
「ええ。」
たつきち:
「DBを物理的に分けるとか、非同期メッセージングとか、Eventual Consistency(結果整合性)とか。図はきれいですけど、現場でやると運用が地獄ですよね。デバッグも大変だし、障害時に誰が責任を持つのかも曖昧になる。」
koriym:
「よく聞く話です。小規模なチームで導入して、オーバーエンジニアリングで自滅するパターンですね。ファウラーも、(本当に必要でない限りは)「CQRSの利用には慎重であるべきだ(you should be very cautious about using CQRS.)」って言ってますね。」
たつきち:
「ですよね。だから僕としては『概念としては分かるけど、実際は無理』っていう位置づけだったんです、CQRSって。それに、かのGreg Young (CQRS提唱者) も 『結局はReadとWriteを分けるだけだ(It’s just separating Read and Write)』みたいなことも言ってますよね。」
koriym:
「……言ってますね。」
たつきち:
「ですよね。だったら、ReadとWriteを分けて整理する、という理解で十分なんじゃないかと。」
koriymはすぐには返さなかった。ほんの一拍置いてから、静かに言った。
koriym:
「その『だけ』を、どう受け取るか、ですね。」
たつきち:
「……と言うと?」
koriym:
「Gregが言いたかったのは、『もっと複雑に考えろ』ではなく、『余計なものを足すな』ということです。Busも、分散も、イベントも、まずは全部忘れていい。それらはCQRSの本質ではありません。」
たつきち:
「そうなんですか……」
間が空く。
koriym:
「それでも残るものが、CQRSで中でも最も大事なもの..Command です。」
たつきちはちょっとキョトンとした。真意が掴めない。
たつきち:
「...Commandって、要は書き込みですよね、ぶっちゃけていえばDBのテーブル更新です。ユーザーや記事をテーブルにINSERTすることですよね。僕は(もちろん)Doctrine ORMでやりますが」
koriym:
「...そう見える、というだけです。」
たつきちは黙ったまま、考える。
koriym:
「じゃあ、こう考えてみましょう。『書き込みが失敗する』って、どういう意味だと思います?」
ディスカッションの分析
このフェーズでは、CQRSに対する認識のズレが焦点となりました。
-
「整理術」としてのCQRS(たつきちの視点):
- たつきちはCQRSを「R/W分離によるコード整理」と捉え、それに付随するCommand Busや分散システムの複雑さを嫌っていました。これは多くのエンジニアが抱く「CQRS=オーバーエンジニアリング」という感覚を代弁しています。
-
「言語」としてのCQRS(koriymの視点):
- koriymは、複雑な実装(BusやEvent)を削ぎ落とし、「Commandとは何か」という一点に絞りました。
-
「書き込み」という固定観念:
- たつきちは「Command=結局は書き込み(Write)処理だろう」という感覚から抜け出せていません。koriymはそれを否定せず、次の章への問いかけにつなげます。
ラウンド6:名もなきif文
たつきち:
「書き込みが失敗する……?それは単純に、バリデーションエラーか、ユニーク制約違反、あるいはトランザクションのロールバックですよね。現場ではExceptionを投げるか、falseを返すだけです。」
koriym:
「技術的なエラーの話ではありません。私が聞きたいのは、『なぜ、その書き込みは拒絶されたのか』という理由です。」
たつきち:
「なぜって……。例えば『在庫がない』とか、『退会済みだから操作できない』とか、業務ルールに引っかかったからでしょう。」
koriym:
「そのルールは、どこに書いてありますか?」
たつきち:
「Serviceクラスです。$user->isDeactivated() をチェックして、真ならExceptionを投げます。そのあとで $repository->save() する。ごく普通のコードです。」
koriym:
「では、その 『チェックしてから save するまで』のブロックに、名前はついていますか?『退会済みかどうかを確認し、問題なければ更新する』という一連の判断と行動。それは、何という名前のメソッド、あるいはクラスになっていますか?」
たつきち:
「いや……名前というか……強いて言えば UserService::update メソッドの一部、ですね。汎用的な更新処理の中に、if文として埋まっています。」
koriym:
「...」
koriymは歩調を緩めずに続けた。
koriym:
「それが、『Commandが存在しない』という意味です。」
たつきち:
「……どういうことですか?」
koriym:
「たつきちさんは今、『更新(Write)』という大きな枠組みの中で考えています。だから、重要なビジネス判断が『ただのif文』として埋没している。CRUDの Update にマップしたせいで、『退会済みなら弾く』という業務の意図が、単なる条件分岐に格下げされてしまってるんです。」
たつきち:
「...いやいや、でも、コードとしてはそこに書いてあるわけだし、機能しますよ?」
koriym:
「機能はします。ですが、意図が見えない。では逆に聞きます。もしその処理が DeactivateUser (ユーザー停止)という独立したクラスだったらどうですか?クラスの責務は『ユーザーを停止すること』だけ。そのクラスは、実行されたらまず『すでに停止済みか』をチェックする。ダメなら、独自の例外UserAlreadyDeactivated を投げて失敗する。成功したら、停止フラグを立てて終わる。」
たつきち:
「まあ、それならスッキリはしますね。やることも、失敗する理由も明確ですから。」
koriym:
「それが Command です。」
たつきちは足を止めた。koriymが本題に入る。
koriym:
「データの書き込み(Write)は、最後に起きる結果にすぎません。Commandの本体は、その手前にある『やっていいかどうかの判断』です。汎用的な update メソッドの中に埋もれていた『名もなきif文』を切り出し、名前を与え、失敗の理由を明確にする。それが CQRS の C、つまり命令です。ビジネスルール、業務とも言っていい。」
たつきち:
「……ちょっと待ってください。じゃあ、僕が今まで『書き込み処理』だと思っていたものは……」
koriym:
「Command(命令)と、Write(永続化)が、ごちゃ混ぜになったものです。だから、それを『書き込み』としか呼べなかった。」
たつきち:
「……」
koriym:
「失敗の理由が『業務』であるなら、それは書き込み処理ではなく、意思決定、つまりコマンドなんです。」
ディスカッションの分析
-
「名もなきif文」の発見:
- koriymは、「Write」という言葉が持つ「データを保存する」というイメージを剥がし、その前段にある「意思決定(判断)」に焦点を当てさせました。
- MVCのService層で、重要なビジネスルールが汎用的な更新メソッドの中の
if文として埋没している現状を指摘しました。
-
Commandの本質:
- Writeは「結果(データの変更)」ですが、Commandは「意思決定(判断と命令)」です。
-
UpdateUserではなくDeactivateUserと名付けることで、それは単なるDB操作ではなく、明確な意図を持ったオブジェクトとなることが示されました。
ラウンド7:二つのドア、一つの部屋
たつきち:
「……なるほど。Commandが『意思決定』であり、単なる書き込みではないことは分かりました。だとしたら、実装としてはこうなりますか?今まで一つの UserRepository に find も save も入っていたのを、UserReadRepository と UserWriteRepository に物理的に分割する。Greg Youngも『ReadとWriteを分けるだけだ』と言っていたわけですし、リポジトリを分けるのがその第一歩ですよね?」
たつきちは、ようやく具体的なコードの形が見えてきた気がして、少し安心した表情を見せた。しかし、koriymは首を横に振った。
koriym:
「それが、最も陥りやすい罠です。
Gregが言った分ける(Segregation)は、『Read/Write Repository Segregation(リポジトリの分割)』ではありません。」
たつきち:
「えっ? リポジトリを分けることじゃないんですか?」
koriym:
「たつきちさん、想像してみてください。入り口(Repository)を読み取り用と書き込み用の二つに分けました。でも、その奥で扱っている『Userエンティティ』が、もし 同じクラス だったらどうなりますか?」
たつきち:
「……同じクラス?まあ、DoctrineのEntityは同じものを使いますよね。読み込む時も、書き込む時も。」
koriym:
「そう、それが問題なんです。
入り口のドアを二つに増やしても、中にある部屋(モデル)が一つなら、それはCQRSではありません。Gregの真意は、メディア(保存媒体)やリポジトリの区別ではなく、『モデル(関心ごと)の分離』なんです。考えてみてください。Command(書き込み側)のモデルに必要なのは何ですか?不変条件を守ること、矛盾した状態を拒否すること、つまり『振る舞い』です。ここにはゲッター(Getter)すら要らないかもしれない。」
たつきち:
「……確かに。判断するだけなら、内部状態が見える必要はないですね。」
koriym:
「一方で、Query(読み取り側)のモデルに必要なのは?画面に表示するための、整形されたデータです。結合(JOIN)され、加工され、使いやすく並べられた『構造』です。ここには複雑なビジネスロジックも、セッター(Setter)も要らない。」
たつきち:
「……言われてみれば、真逆ですね。片方は『ガードが堅いブラックボックス』で、もう片方は『明け透けなデータ構造』だ。」
koriym:
「その通りです。それなのに、私たちは『Userエンティティ』というたった一つのクラスに、その両方の責務を負わせてきました。だからエンティティは、バリデーションロジックと、表示用のゲッターで膨れ上がり、複雑怪奇になる。
Gregが『分けるだけだ』と言ったのは、『それら二つは全く別の生き物なのだから、無理に一つのクラスで表現するな』という、極めてシンプルな提言だったんです。」
たつきち:
「うわあ……。僕、完全に誤解していました。『リポジトリを分けて整理整頓する話』だと思っていたけど、実際は 『一つのモデルで全てを解決しようとするな』 という、モデリングの話だったんですね。」
koriym:
「ええ。R/W分離の本質は、『最適化の方向が違う二つのモデルを、独立させること』です。Repositoryを分けるのは結果であって、目的ではありません。」
ディスカッションの分析
-
「リポジトリ分割」というミスリードの解消:
- 「Repositoryを分ける=CQRS」という、形だけ真似て本質(モデルの分離)を逃す典型的な失敗例が指摘されました。
-
「同じ部屋」の比喩:
- 「ドア(Repository)を二つにしても、部屋(Entity)が同じなら意味がない」という比喩で、MVCフレームワークにおける「万能エンティティ(God Class)」の問題点が浮き彫りになりました。
ラウンド8:使い捨ての美学
たつきち:
「モデルを分ける……。Command側が『判断のためのモデル』だというのは分かりました。でも、Query(読み取り)側はどうなるんですか?今まで通り、DoctrineのEntityを使ってデータを取得するんじゃダメなんですか?」
koriym:
「CommandとQueryは、求められる要件が正反対なんです。Commandは正規化され、整合性が保証された『正しい状態』が必要です。一方で、Queryに必要なのは何だと思いますか?」
たつきち:
「それは……画面に必要なデータ、ですよね。」
koriym:
「そうです。画面に表示するための、整形されたデータです。そこには、テーブルの正規化なんて関係ない。複数のテーブルがJOINされ、合計値が計算され、使いやすく並べられた 『構造(Structure)』があればいい。そこには、複雑なビジネスロジックも、バリデーションも、セッター(Setter)さえも要りません。」
たつきち:
「セッターも要らない……。まあ、表示するだけですからね。でも、そのためだけに専用のクラスを作るんですか?せっかく User エンティティがあるのに、似たような UserDto みたいなものを作るのは、DRY原則に反するような……。」
たつきちは、「同じようなクラスが増える」ことへの生理的な拒否感を示した。しかし、koriymは楽しそうに笑って言った。
koriym:
「たつきちさん、Greg YoungはQuery側のモデルについて、なんと言っているか知っていますか? Queryモデルは使い捨て(Disposable)でいい』と言っているんです。」
たつきち:
「つ、使い捨て……!?」
koriym:
「ええ。Queryモデルは、ドメインの真理を表すものではありません。『ある瞬間の、ある画面の都合』に合わせて切り取られた、ただのスナップショットです。だから、永続的にメンテナンスする高貴な『ドメインモデル』である必要はない。画面が変われば、捨てて作り直せばいい。Gregはそう言ってます。」
たつきち:
「!! 」
koriym:
「私たちは今まで、その『使い捨てでいいはずのデータ』を取得するために、重厚なDoctrineエンティティを使い、リレーションを張り、Lazy Loadingに怯え、シリアライザーで循環参照と戦ってきました。でも、Queryを分離するなら、そんな苦労は一切不要になる。必要なカラムだけをSELECTした、単純なクラス(DTO)があればいいんです。」
たつきち:
「……なんか、力が抜けました。僕は『正しいモデルを作らなきゃ』と必死でしたが、読み取り側に関しては、もっと 『適当(割り切り)』 でよかったんですね。」
koriym:
「適当、ではありません。『最適』なんです。読み取りに特化した形に最適化すること。それを許容するのが、CQRSのもう一つの自由です。」
ディスカッションの分析
-
「使い捨て(Disposable)」の衝撃:
- 「Entity=唯一の正解」という固定観念を持っていたたつきちに、Greg Youngの「Queryモデルは使い捨てでいい」という言葉をぶつけることで、「データモデルの神聖化」 を解体しました。
-
Commandとの対比:
- Commandモデル=高凝集・厳格(判断)
- Queryモデル=使い捨て・柔軟(表示)
- この対比により、「なぜ分けるのか」の理由が、整理整頓ではなく 「それぞれの責務に特化させるため」 であることが明確になりました。
ラウンド9:失われた言葉たち
たつきち:
「……あのー、一つ気づいてしまったんですけど。」
koriym:
「なんでしょう?」
たつきち:
「Commandは『意図』、Queryは『構造』。Gregはそう言った。でも、僕らの周りを見てください。MVCのモデル、REST API、Scaffold。これ全部、『意図』も『構造』も、十把一絡げにしてCRUDに押し込んでませんか?」
koriymが静かに頷く。
たつきち:
「なんでもかんでもCRUDでやろうとするから、うまくいかないんじゃないですか。本来は『業務の遂行』という意図を表すべきところを、単なる『データ操作』としてしか捉えていない。でも、それって結局、MVCフレームワークのMが『データ操作』になってしまっているせいでもありますよね。当たり前すぎて疑問にも思わなかったですけど」
koriym:
「ええ。モデルがいつの間にか『データベースの代理人』になってしまった。ユーザーは『行をUPDATEしたい』わけじゃない。『引越しをしたい』んです。でも、フレームワークが用意したMが『住所カラムの書き換え』しか表現できないなら、コードもそれに従うしかない。」
たつきち:
「だからFat Controllerになるし、ロジックも散らかるわけか……。僕はずっと『Doctrineの使い方』の問題だと思ってました。でも違った。CRUDという枠組みだけで、複雑なビジネスを表現しようとしたこと自体に、そもそも無理があったんですね。」
koriym:
「CRUDの引力は強いですからね。それだけ利便性をもたらしてきたのも事実です。しかし意識して『意図』をコードに残さないと、すべてはただのデータ更新に飲み込まれてしまいます。」
夜の冷気の中、二人の足音だけが響いた。
特定のライブラリがどうとか、アーキテクチャがどうとか、そんな話はもう出てこなかった。ただ、「なぜ自分たちの開発はいつも辛いのか」という問いの答えだけが、重く、しかし確かにそこに横たわっていた。
(完)
編集後記
前回の「貧血モデル」の議論が「現状の肯定(テスト容易性)」で幕を閉じたのに対し、今回のCQRS編は、その足元にある「構造的な絶望」に触れる展開となりました。
多くのエンジニアがCQRSという言葉を聞いたとき、反射的に「マイクロサービス」「イベントソーシング」「結果整合性」といった“複雑な実装詳細”を思い浮かべます。劇中のたつきちもそうでした。しかし、提唱者であるGreg Youngが伝えたかった本質は、もっとシンプルで、かつ残酷なまでに根源的な問いです。
あなたはビジネスの意図(Command)を語っているか? それとも、ただデータベースの更新(CRUD)を語っているか?
CQRSは単なるデータモデルの分離ではありません。また今回の対話で描きたかったのは、特定のアーキテクチャの優劣ではありません。現代のWeb開発において、MVCフレームワークやORMといった便利な道具が、いかにして私たちの思考から 「動詞(意図)」を奪い、無機質な「データ操作」へと均質化させているか、という事実です。
「コントローラーが太る」「ロジックが散らかる」といった現場の悩みは、エンジニアのスキル不足だけが原因ではありません。そもそも 「意図を受け止める場所」が用意されていない構造の上で、必死に意図を表現しようとするから——しかもCRUDという思考フレームワークとその道具を手放すことなく——歪みが生まれるのです。この構造に気づかない限り、終わらない「ついに辿り着いたアーキテクチャの正解」の話は、また来年も繰り返されるのではないでしょうか。
CQRS
CQRSの本質的洞察については以下の2つの記事もご覧ください。
この2つの記事の洞察は素晴らしく、以前 Greg Young 氏に査読を依頼した際も、そのCQRS理解が適切なものであるとお墨付きをいただいています。
これらで紹介されている task-based-ui の理解こそが、CQRS理解の鍵です。中でもNaokiTsuchiyaさんが紹介しているGregのRESTとCQRSに関するツイートは、彼が考えるCQRSの本質をよく表しており、この記事の主題とも関連します。
(Gregは当初、RESTを「単なる CRUD over HTTP のようなもの」だと誤解していたのですが1、後にRESTをHATEOASの文脈で再理解しました。そして、RESTの本来の姿(ハイパーメディア駆動)が、CQRSの理想とする「振る舞いの分離」と完全に合致することに気づき、前言を撤回・修正したのです。)
CQRSは魅力的な考え方ですが、どのように実現するかの How ばかりが語られ、肝心の本質、それがなんであるかの What が置き去りにされたり、誤解されているように感じることがあります。(前回の記事で取り上げたMVCのモデル(そしてDDD)もまた、同じ文脈です。)この記事が、アーキテクチャのWhatを問う姿勢に少しでも繋がれば幸いです。
次はいよいよ完結編へと続きます!
-
RESTもCQRSと同じように広く誤解されていますが。 ↩
