Domain の地図
新しいシステムと出会うとき、最初に浮かぶ問いは決まっている。何のためのもので、何をするのか。これは意味についての問いであって、造りについての問いではない。ところが最初に返ってくる答えはたいてい基盤のほうだ — コントローラ、サービス、リポジトリ、マイグレーション。その向こうに業務領域は見えない。
Domain の地図こそ、その最初の問いへの答えだ。そこにあるのはシステムを構成する領域だけ — StoreDomain は注文の管理、BillingDomain は請求、MessagingDomain は通知。細部を持たないこの答えはシステム全体を覆い、しかも頭に収まる。最初の足がかりとして。
StoreDomain注文の管理
BillingDomain請求
MessagingDomain通知
Entity が Domain を言い表す
Domain の主役は二つある。Entity — 注文、顧客、明細 — と、それらに対する名前のついた操作である Action だ。コントローラもテーブルも、その他の基盤も入ってこない。おかげでもう一段深く降りながら、視線はシステムの業務上の意図に留まり、技術的な実装へ落ちない。
Entity はごく普通のクラスだ。名前と、型のついたフィールドと、それだけ。ORM も知らず、テーブルも知らず、データがどこから来るのかも知らない。その唯一の仕事は、現実世界の対象を人が語るとおりに書き表すこと — 注文には金額と通貨があり、顧客には名前とメールアドレスがある。フィールドは二種類ある。単純なもの — 文字列、数値、日付 — と、他の Entity への参照だ。注文には顧客がいて、明細がある。後者こそがばらばらのクラスの集まりをモデルに変えるもので、次の節はそれを扱う。なお Entity に振る舞いはない。書き表すだけで、対象に対して行われることはすべて Action に住んでいる。
Action とは何か →関係が所有をはっきりさせる
Domain が何でできているかは分かった。では何がそれを支え、関係が食い違わないようにしているのか。
関係は二つのことで言い表す。対象どうしがどれだけ強く結びついているか — コンポジション、集約、関連 — と、それぞれの側に対象がいくつ立つか、一つか複数か。どの Entity も関係を丸ごと宣言する。自分の側と向かい側、その両方に型と多重度を添えて。だから一つの Entity を見るだけで、その関係のすべてが一度に見える。起動時にシステムは各組を突き合わせる — 食い違えば、そこで起動は止まる。ときに向かい側が存在しないこともある。関係が自分のデータベースの外を向いている場合だ。それも許されるが、明示的に宣言する。
Lifecycle がすべての遷移を管理する
ステータスがただの文字列であるかぎり、注文が下書きからいきなり配達済みになるのを止めるものは何もない。何が許され何が許されないかは、チームの頭の中か、とうに古びたコメントの中に住んでいる。
AOA ではこの規則が口伝えでなくなる。注文の状態とその間の遷移が、Entity のすぐ隣に状態機械として宣言される — 下書き、支払済、発送済、配達済。そして取り消しは最初の二つからしかできない。これは推奨ではない。一覧にない遷移は存在せず、支払われていない注文が配達済みになることはありえない。状態機械そのものも起動時に検査される。たどり着けない状態や、どこへも行かない遷移があれば、最初の注文が一件も通らないうちに起動が止まる。
状態機械はそれ自体として宣言される。注文がとりうる状態と、その間で許される遷移。一度だけ、どの Entity からも切り離して。
そのうえで適用できる。OrderEntity の status フィールドは文字列であることをやめ、OrderLifecycle 型になる。文字列はどんな値でも受け取った — この型が受け取るのは、そこへ至る道がある値だけだ。
Resource が保存先との境界を引く
モデルは業務領域を記述するが、データベースと直接やり取りはしない。その実装に依存してはならないからだ。ならば、現実のデータベースからデータを取り出し、また戻す誰かが要る。
それが Resource の仕事だ。呼び出しをまたいで生き続けねばならないものを、Resource が抱える — 接続、プール、クライアント。その務めは開き、実行し、返すこと。業務規則は中にない。この境界を越えていくのが Entity だ。同じ一つのモデルが、複数の Resource に同時に表れうる。一つは SQL から読み、もう一つは NoSQL から、三つ目は他所の HTTP サービスから読み、そのすべてが同じ OrderEntity を返す。業務ロジックのコードは、どの Resource が働いたのかを知らない。テーブル名も、方言も、応答の形式も、そこへは届かない。だから保存先を替えるとは、Resource だけを替えることだ。
射影が必要な断面を作る
同じ一つの Entity が、あるときは丸ごと、あるときは部分的に、あるときは識別子だけでデータベースから読まれる。これに対する普通の答えが DTO の動物園だ — 場合ごとに一つずつクラスを作る。
普通のやり方は、Resource のメソッドごとに専用の DTO を用意することだ。素の辞書を返すのと違って、これはフィールド名の打ち間違いに対する静的な保護を与えてくれる。だがこのやり方には別の面がある。元の Entity は多数の小さな DTO へと砕け、業務領域のモデルはドキュメントと図面の中にしか残らない — そしてそれらは最初のリリースを迎える前に古びる。
AOA の考えは、まとまったモデルを手放さないことにある。Resource の操作は、宣言されたとおりのモデルの対象そのものを返す。フィールドは DTO と同じように名前のついた属性で読めるのに、新しいクラスは増えない — そしてモデルに存在しないフィールドを紛れ込ませることは不可能になる。モデルが今も正しいことは、それを使うコード自身が裏づける。ERD もそこから直接描かれる。
そこで、データの射影という考えが導入される。Resource のメソッドが Entity を丸ごとは読まないとき、返るのは同じその Entity だが、データベースから読まれなかったフィールドには「利用できない」印がつく。最初に使おうとした時点で、システムは黙り込まずに、分かりやすい例外を投げる。
Maxitor の ERD モデル →get_order(order_id) → OrderEntity
分かりやすい場合 — Entity 全体、すべてのフィールドが読み込まれている。宣言されたとおりに使われたモデルのクラス。
find_orders(query) → list[OrderEntity]
薄い一覧 — 各注文の id だけが読み込まれている。他のフィールドを読めばエラーになる。
get_full_order_info(order_id) → OrderEntity
深い場合 — 注文と、その入れ子の Entity。それぞれが自分の部分的な深さまで読み込まれている(id は鍵なので常に読まれる)。
