継続的に価値を提供するドメイン駆動設計入門
世界へ継続的に価値を提供するコストはもっと低くてもいいはずだと思う。
継続的な価値提供への一つの手法としてドメイン駆動設計があるが、特にアプリケーション設計においては大局的な理解を得ることが難しいように思われる。
ドメイン駆動設計によるアプリケーション開発においてどういうことを実現したいのか。
本記事では大局的な思想に注目することで設計の入門となることを期待したい。
ドメイン駆動設計で成したい欲求
アプリケーション提供において、開発は専門化し価値提供欲求と分断されることが起こりがちである。加えて、アプリケーションの大規模化に伴い開発内においても局所的な専門化が生じやすい現状がある。
特定の目的を達成することで価値を提供するアプリケーションにおいて、想定する概念の認識がチーム内で分断されることの弊害は大きい。
ドメイン駆動設計においては役職を超えてチーム内で想定する概念の認識を揃えることで乖離をなくし、想定する概念の表現(モデル)を共に育てることで相乗効果を生む。
加えて、価値の提供手段においても想定概念との協調を図ることにより、モデルとともに成長し継続的に価値を提供し続けられる提供手段となることを期待したい。
本記事で伝えたいこと
ドメイン駆動設計の最も重要な要素はチーム内で概念認識を共有し昇華し続ける「ユビキタス言語」の作成であると捉えているが、ユビキタス言語の作成には総員で献身的にコストを投入する必要があり、早期に手応えを還元することはチームモチベーションのコントロールに重要と考える。
開発とユビキタス言語の作成は相互に行き来することで昇華していくものではあるが、開発フェーズでの落胆による失墜を防ぐため、無鉄砲に広範囲の導入をする前に開発の根幹については手応えを得ておき、方針には習熟しておきたい。
よって本記事では主に開発者に向けてアプリケーションのアーキテクチャ設計について触れていく。
なお、ユビキタス言語に始まるモデリングの勘所については エリック・エヴァンスのドメイン駆動設計 で十分把握できるため原典にあたっていただくことをおすすめする。
ドメイン駆動設計における実装
ドメイン駆動設計においては以下の 2 つを区別することが重要となる。
- 概念層
- 対象アプリケーションの概念を実装で 表現 する層
- 非概念層
- 対象アプリケーション外との帳尻合わせ、技術的詳細
概念層
更に、概念層は domain 層と application 層の 2 層に分かれる。
- domain 層
- 対象とする領域の各概念、要素が何であり何でないのかを表現する層。
- 提供手段に依らず存在しうる概念をここで表現する。
- application 層
- domain 層で描く概念は何かしらの提供手段により提供される。我々はソフトウェアによってそれを提供し、アプリケーションと呼称することが多い。
- このアプリケーションがどのようなものであり、どのように挙動するのかを概念として表現するのが application 層である。
- application 層では技術的詳細には立ち入らず、どのような要素がありどう組み合わせて使用するのかの表現に留める。
- 一方、アプリケーションの知識としてのロジック(パーツの条件分岐など)は積極的に実装として表現する。
これら概念を表現する概念層ではとにかく表現力にこだわり、モデリングとの親和性を高め相乗効果を狙いたい。
非概念層
概念層では表現力にこだわり、外側の技術的詳細は「それ以外」とし、概念層の表現力を高く保つことが重要である。概念表現以外を担うのが非概念層である。
非概念層は adapter 層と infrastructure 層に分かれる。
- adapter 層
- 概念の外殻を担い、外部(含:技術的詳細)との調整を行う層。
- 概念層では表現力にこだわり、adapter 層で帳尻合わせを行いたい。
- infrastructure 層
- より低レイヤーな技術的詳細。driver や protocol などの詳細実装。
- アプリケーションの知識というよりは外部ライブラリとして扱う方が適切なことが多く、筆者はアプリケーションとは別管理にすることが多い
全体を図にすると以下のようになる。
- 戻り値の矢印は省略してあるが、すべての矢印に対して存在する。
- infrastructure 層はレイヤーに依らず活用するライブラリの役割を担うため、図には出てこない。
概念層では概念の表現に注力し、モデリングと合わせて育てることで継続的な価値提供が可能なアプリケーションとなることが期待される。
以上が大局的な設計の方針であり、各層で成したい目的を強く意識して実装することが鍛え続けられるアーキテクチャを実現するために重要となる。
層構造のまとめ
重要な点をまとめると以下のようになる。
- 概念層では実装により概念を 表現 することに注力する
- domain 層は提供手段に依らない根本的な概念を表現する層である
- application 層は提供手段の概念を表現する層である
- adapter 層により application 層と外界との帳尻合わせを行う
ここまでが層構造の概要である。ドメイン駆動設計の書籍で紹介されている集約やドメインサービスなどの各戦術要素は概念層の表現手法の一つであるため、知見として活用するのには大いに役立つ一方、要素ありきで原典準拠を求めるものではないことに注意したい。
とはいえパターンの活用により得られるものは大きいため、モデリングや実装の改善とともにパターンを学習していくのはおすすめする。
各層の具体的な実践指針
現実にはどの層に何を配置するか迷いが生じることが多い。
具体的な設計の指針としては擬似的に各レイヤーに視点を起き、立って見回すことをおすすめしたい。
オニオンアーキテクチャのようなものを想定して欲しい。周囲は壁に囲まれており、立っている層より内側しか見えない。外側は壁と扉のみであり、インターフェースまでしかわからない。インターフェースを決める権限は内側に託されている。外界の詳細を妄想するのではなく、内側から見た理想を描くことを基本としたい。
domain 層
domain 層では「提供手段がソフトウェアである」とは認識せず対象の概念知識しか知らない。外は見えない。見えるもののみを表現する。
一方、見えるものは積極的に表現する。詳細なロジックについても概念知識であれば実装で表現する。
表現に使えるパターンとしては「集約」「仕様」「エンティティ」「値オブジェクト」「ドメインサービス」などが代表的かと思うが、パターンの原典準拠よりも 何であり何でないのか を表現することが重要である。
表現力に関する一つの例として、筆者は差し替え可能であることを是とし何でもインターフェースにすることには反対である。そのインターフェースにした概念は、 概念として 差し替え可能なのか?違うなら混乱を招くノイズなので実体のみで表現したい。過度な抽象化を避けることも表現力の重要な要素である。
実装の詳細を混ぜない〜という言及から entity のようなものだけが入ると誤解されることも多いが、ロジックも含めてのドメイン概念である。
application 層
application 層では「提供手段」の概念知識を表現する。我々はアプリケーションをソフトウェアにより提供することが多い。
ソフトウェアにおいてはデータの入出力によりドメイン概念への影響を生じさせることが多く、価値提供のためにはアプリケーションにデータの入出力とやりたいこと(= usecase)を定義することが主軸となる。
また、アプリケーションは周囲と連携して価値提供を実現することが多く、何らかの外部通知処理やデータ取得処理、認可などの機能呼び出しが必要になると想定される。これらも application 概念として表現する。
例えば、domain 概念の範囲においては明示的に「永続化」するまでもなく作用した時点で完結している。一方、ソフトウェアでのアプリケーション提供においては往々にしてメモリ内では完結せず、永続化や同期が必要になる。アプリケーションとしての事情であれば application 層で表現するのが適切と考えられる。(もちろん、domain 概念として永続化の概念があればそれは domain 層で表現する。)
domain 層と同じく application 層からは外が見えない。見える範囲で(必要な)想定できるものを配置する。知る必要のない、詳細がわからないものは interface だけ配置する。
application 層で表現するのは「どのようなものがあり、どう組み合わせるか」という概念知識である。価値をどのように提供するかには深く立ち入り、その各パーツがどのようにそれを実現するか(技術的詳細)には立ち入らない。
例えばトランザクションの仕組みが必要なら導入する。実現の技術的詳細は知らないので interface でいい。adapter 層でなんとかする。interface によりトランザクションを持つ実装に手段が限定されるのは正しい状態である。必要なパーツ(概念)なのであれば。
あくまで外部(他アプリケーションやインフラなど)の事情を知らずにソフトウェアとしての対象アプリケーション領域内の知識で完結させることが判断基準となる。
adapter 層
アプリケーションは周囲と連携して価値提供をする。連携するための実装を配置するのが adapter 層である。
外部との通信など、ソフトウェアのインターフェースとアプリケーション概念との帳尻を合わせる。
例えば通信仕様に即したデータオブジェクトを定義し、アプリケーション概念内のドメインオブジェクトとの相互変換を担い、通信実装との入出力を実現する。
概念層を清浄に保てば保つほど、adapter 層での退屈で筋力の要る実装は肥大していく。adapter 層の犠牲により高い価値を提供し続ける概念層が保たれるため、内外の変化に順応しやすい設計とできると望ましい。(筆者は主に過度な共通化を避け、捨てやすさを重視したシンプルな実装で満たすことで順応性を高めることが多い。)
とはいえ adapter 層の実装工数は無視できないリスクとなる。ストレスとなる実装は過度の使い回しなどの「ズル」を招き、結果として破綻へと繋がりやすい。強制力による解決も現実的ではないので、多少の妥協により概念層を汚すことも多々ある(データ変換用の記述を domain 層に置いたり、テスト用の実装を追加したりなど)。トレードオフの取捨選択は手腕が問われる要所であり、チームで共通認識とすべき勘所である。
infrastructure 層
アプリケーションに依らず必要となる汎用的なライブラリを実装する。管理都合を除けば同じリポジトリで管理する必要はなく、外部ライブラリ化していきたい。
全体としての実践手順
抽象的な層構造について理解したら次は実践手順の話となる。
まずはどの層から手を付けるか?という議論には概念層の好きな方から、と答える。肝となるのは概念層でいかに相乗効果を生むかであり、詳細に立ち入る前に概念を描きたい。
domain 層と applicaiton 層の順序はどちらでもいい。モデリングと併せて高速に行き来するものである。
忘れがちだが、概念層はモデリング結果を実装に落とし込むだけの層ではない。概念層での実装表現からもモデリングにフィードバックを与え、共に昇華していくことが重要である。
例えば「財布」のようなものを表現するときに、入出金の履歴をいかに持つかが議論になる。初期のモデリングで全てを「財布」が保持していた場合、実装上は有限個しか保持することは難しいため乖離が生じる。この場合、実装により回避(有限個だけ持たせ、それ以外は通信が生じる呼び出しにより解決など)とするよりも「レシート」のような概念を導入し「財布」には直近のもののみ保持し、定期的に「レシート台帳」に移す、というモデルを検討するなど、相互に影響し合うことで発展させていくほうが望ましい。
実践手順においてはチーム事情によるところが非常に多いため、柔軟に対応することが求められる。高い価値提供へつながるモデリングと概念層の実現に繋がれば手法は問わないため、例えば「まずは動くようにしてから改善を回す」といったアプローチも積極的に活用していきたい。断絶を防ぎ相乗効果を生み続けることが重要である。
参考までに筆者のフローを例として上げると、ざっくり domain 層に要素を仮置きし、早い段階で application 層で試行錯誤し違和感を抽出することでモデルへのフィードバックを素早く得、ユースケースの洗い出しをした後にフルスクラッチで工程を繰り返す、というアプローチを好んでいる。2,3 回繰り返すと概ね勝算が得られることが多い。永続化などの技術的詳細は可能な限り(動かしたいという人に突かれるまで)後に回す。可能な限り、概念層の実装がモデリングの DSL として活用できるよう工夫する。
アプリケーションのスコープ
人間は多くのことを同時に考慮することはできない。考える領域を適切に区切る(スコープを区切る)ことが必要となる。
アプリケーション内の層構造もスコープの一種であるが、複雑度を軽減するためにはアプリケーション自体のスコープも必要となる。ドメイン駆動設計では「境界づけられたコンテキスト」と呼称される。
スコープによりモデル内の複雑度は軽減され、概念要素間もスコープ間では疎結合に保てる。
同じチーム内であっても、似たようなモデル要素であっても、コンテキストをまたがる要素は独立したものとして扱い adapter によって仲介させる。
実践手順についてのまとめ
ここまでを理解した上でドメイン駆動設計の原典に触れると各戦術要素の意義が捉えやすくなるはずである。なぜその要素がほしいのか、欲求から考えられると自身のアーキテクチャへの取捨選択の助けとなる。
重ねて述べるが設計を含めた戦術はあくまで手法であり、開発において重要なのは価値提供欲求との親和性である。価値提供欲求とともに鍛え続けられる提供手段を提供した上で、提供できる価値の向上に努めることが目的である。
相乗効果を生めるよう、各レイヤーでの目的を強く意識し相互にフィードバックを回すことが勘所となる。
ドメイン駆動設計の(著者の)現状と課題
最後に、おまけとしてドメイン駆動設計を実践する上での悩ましい諸問題について触れたい。
個人的にはある程度の解決策を見出しているものもあるが、良いアイデアがあれば是非アドバイスをお願いしたい。
Go 言語の表現力が足りない
著者は Go 言語を主に活用しているが、シンプルかつ素直な実装となりやすく、実装の主旨を汲み取りやすいところがメリットである。
反面、表現力を高めるのに筋力(コード量)が必要となることが多く、特に「何ができないか」という制限を表現する場合に悩みが多い。
一例としては「必須値」を表現する required や、「読み取り専用」である readonly を表現するのですらプライベートフィールドとコンストラクタやアクセサが必要となり、一つ一つ手で実装するとコストが跳ね上がる。
また、実装自体も意図を伝えるにはコード量がノイズとなりやすく、フィールドに連動した挙動であるのに実装箇所も散りがちで把握しづらい。
著者は一つの解として struct tag に情報記述の上で code generator を活用する手法を用い、厳密な制限について(package private なので他オブジェクトから変更できてしまう)は意図の表明を明確にすることに留めて一定の妥協をしている。
- go-codegen/codegen/go_accessor at master · hori-ryota/go-codegen
- go-codegen/codegen/go_constructor at master · hori-ryota/go-codegen
表現力を保つための実装コストが高い
表現力を高めるために導入する型安全なエラー型や、結果整合などに活用するアプリケーションイベントなど、意図したい概念のシンプルさに対し実装コストが高く感じるものが多い。
また、概念層だけでなく adapter 層においても関連実装が必要になり、全体としての実装コストを高騰させる。加えて概念層に、概念の表現としてはノイズとなるコードが大量に生じ、見通しの悪い実装となる。
著者はコード中にコメントによる定義記述(例://errcode InvalidWorkingTime,startAt int64,endAt int64
)を仕込み、 code generator により各実装を生成するアプローチを取っている。
adapter 層の実装がつらい
アプリケーション間の連携には server と client の形式を取ることが多い。現在ではマイクロサービス構成にしてネットワーク通信を挟むことも多い。
境界づけられたコンテキストは小さく保ちスコープを狭くすることで各アプリケーションをシンプルに保てるが、反面アプリケーション間の連携を実装するコストが跳ね上がる。
API 定義からのコード生成によるアプローチも考えられるが、筆者は usecase の入出力と client は密結合な概念であると考え、usecase の入出力から server, client を生成するアプローチを好んでいる。
なお、client には Go 言語だけでなく Kotlin も想定することで、Kotlin Multiplatform の恩恵により全プラットフォーム向けの client ライブラリを提供することを狙っている。
永続化に代表される他の adapter についても可能な限りコード生成による省エネ化を図っていきたい。
プライベートフィールドの扱い
Go 言語においてだが、特に永続化のためにプライベートフィールドのデータを他 package から取得したくなることが多い。domain 層を汚したくはないが、dump と restore により露出するような実装が必要となってしまっている。
Query 側へのアプローチ
ここまではドメイン知識に基づく実装について言及したが、実サービスにおいては閲覧のためのデータ取得が必要となる場合が多い。
特定のドメイン要素への作用を意図した操作であればドメイン駆動設計の領分であるが、データ閲覧を目的とした取得操作には別の対応が必要となる。
筆者は CQRS という作用(Command)と取得(Query)を分離した設計方針を活用したいが、素直に実装すると Query 側を実現する実装コストが高く Command 側の要素を転用したい欲求にかられる。
Query 側へドメイン知識の流出が生じることも問題な上、コード生成を活用しても Query 側のデータオブジェクトは取得側の事情へ柔軟に対応したいため、単純な dump 的なコード生成では破綻する可能性が高いことも悩みを深める。
他の generator でも活用しているが、Go 言語の型を 2 つ指定すれば対応するフィールド及びアクセサを用いて convert する処理を生成する仕組みは実装したので、補助的な generator の活用でアプローチできないか模索中。
frontend 側へのアプローチ
ドメインオブジェクトには backend でも frontend でも共通で用いたいものが多い。特に validation などに用いる仕様は API と同じく連携コストを肥大させる要因であり、かつ本来は frontend で管理したいものではないので backend から生成できれば恩恵は大きいと考えている。
application 概念も含めた全概念を同期させるのは取得できる情報や実処理(永続化 or 通信による移譲 など)の差によって単純には難しいと考えている。validation ルールだけでも同期できると嬉しいが、せっかくなのでもう少し恩恵を大きくしたい。
(なお、API と違いロジックを含む概念層の Go 言語実装を Kotlin に変換するところも悩み中である。おとなしく最初から backend を Kotlin で実装すれば変換は楽になると思われるが、今度はドメイン駆動設計の DSL として活用している Go 言語の良さとのトレードオフになる。)
チーム境界
CQRS のアプローチを取り、backend と frontend でモデル共有ができたとき、チームは backend/frontend ではなく command/query で分掌できるのではないかと考えている。コンテキスト間も含めて人は相互に横断するが、チーム単位としては command/query で帽子を被りかえられるのではないだろうか。
ドメイン知識に興味が強い command チームと、UI に興味が強い query チーム。うまく行けば実装上、仕様上でも相互に汚染しづらい鍛え続けられるアーキテクチャ&チームとなるのではないかと検討している。
(Go 言語 →Kotlin Multiplatform 生成な Command チームと、BFF も活用しつつの取得用 backend&Flutter な Query チーム、なんて夢を抱いている)
おわりに
ドメイン駆動設計については多くの有益な言及がありますが、ドメイン駆動設計の対象は広くて深いため、全体地図が描けるまでは迷子になりがちです。
ドメイン駆動設計本を呼んでいても実装の各戦術要素に着目しがちなこともあり、そもそも何を成したいのかという目的意識を見失いがちです。設計の根本的な目的の整理からアプローチすることで一助となればと思い記述してみました。継続的に価値を生み続ける助けとなれば幸いです。
code generator を中心とするアプローチはまだ自分も道半ばなので、継続的に発信していく予定です。ぜひ交流いただいて知見共有ができれば嬉しいです。