Learning Domain-Driven Design / ドメイン駆動設計をはじめよう
1. ビジネスドメインを分析する
ビジネスドメインとは、会社の主たる活動領域のこと。複数の場合もある。 例えば、Fedexなら運送、スタバなら飲食、ウォルマートなら小売。
ドメインの目標を達成するために、複数のサブドメインが構成される。 例えば、スタバならコーヒー製造、物流、不動産、人事、金融など。
サブドメインは、Core, Generic, and Supporting の3種類がある。
- Core Subdomains
- ビジネス固有かつ複雑な領域
- 金を生む
- 競争力になる
- 他社に対する参入障壁として機能する
- 頻繁に変更される
- 技術によるものとは限らず、人によるものの場合もある
- e.g. 宝石屋ならデザイン部門
- Generic Subdomains
- ビジネス固有ではないが複雑な領域
- ビジネス固有ではないので、既成のソリューションがあり、競争力にはならない
- "Known unknowns"の領域
- e.g. ユーザーの認証システムなど
- Supporting Subdomains
- ビジネス固有かつシンプルな領域
- ビジネス固有なので、既製品はない
- シンプルなので、競争力にはならない
- リソースを割いてもしょうがない領域であり、アウトソース候補である
- ETLやCRUDのようなインターフェースを取ることが多い
サブドメインの発見は、ユースケースを基準に行うとよい。 特定のアクターが、一定の範囲のデータとそれに対応するユースケース群を使って、 業務フローを完結できる単位が、一つのサブドメインと言える。
ドメインエキスパートとは、ビジネスルールとプロセスを知っている人のこと。 ソフトウェアで問題を解決してあげる対象者であり、エンドユーザーともいえる。
2. ドメイン知識を発見する
問題
ビジネスにおける問題とは、簡単に解けるクイズのようなものではなく、 もっと幅広い意味を持つものだ。例えば以下のようなものがある。
- 非効率なワークフロー
- 人の手がかかる高コスト作業
- リソースの非効率的な分配
- 意思決定のための情報の不足
- 大量のデータの管理
これらの問題にソリューションを提供するのが、ビジネスの目的である。 コードを書くという作業は枝葉の話である。成功に必要なのは問題の理解である。
ユビキタス言語
従来、問題からソリューションまでの道は「ドメイン知識->分析モデル->仕様->システム設計->実装」のような流れだった。 これは一方通行的であり、開発者と現場の相互の意見交換がないので、問題理解が浅いままになりがち。 また、多くの変換の過程で情報が失われたり陳腐化したりする。 結果として的はずれな解決策が生まれる。
それよりも、あらゆる関係者がユビキタス言語を使うべきだ。
ユビキタス言語とは、ドメインエキスパートのビジネスドメインに対する理解とメンタルモデルを、 技術の言葉ではなくビジネスの言葉で表現したものである。 あらゆる関係者がユビキタス言語を使えば、問題理解が促進され、変換も不要になる。
ユビキタス言語は全ての関係者が使うべきだし、 あらゆる場所(会話、文書、テスト、図、コード)で使うべきである。 これを守るのが一番大事で、忍耐と監視が必要。
1つの言葉は1つの概念をきっちり表すものでなければならない。 曖昧な言葉は、より具体的な複数の定義に分けよう。 似たような言葉は、コンテキストを意識して明確に使い分けるか、もしくは統合しよう。
モデル
モデルとは現実世界の要素を簡略化・抽象化して表現したもの。 モデルは特定の目的や問題解決(≒システム)のために作られる。
なので、モデルが単に現実世界のコピーになってはダメであり、 目的達成のために必要となる情報を過不足なく得られるよう、 現実世界の要素を取捨選択しながら作る必要がある。 例えば地図でいうと、目的に応じて世界地図、電車路線図、測量図など様々なモデルがあるように。
ユビキタス言語は、ドメインエキスパートのメンタルモデルを捉えるためのモデルといえる。
ユビキタス言語は継続的に進化していくべきものであり、ツールの活用は大事。 「名詞」の管理、つまり用語集をつくるにはwikiを使うと良い。英語の定義は必須で、必要に応じて他言語でも定義する。 「動詞」の管理、つまりシナリオの管理にはGherkinを書いて管理すると良い。
Feature: 書籍の貸出
図書館員が利用者に書籍を貸し出すことができる
Background:
Given 図書館システムが稼働している
Scenario: 通常の貸出
Given 会員 "山田" の貸出冊数が 2冊
And 書籍 "吾輩は猫である" が貸出可能
When 山田に貸し出す
Then 貸出が完了する
And 山田の貸出冊数が 3冊になる
And 書籍が "貸出中" になる
Scenario: 貸出上限エラー
Given 会員 "鈴木" の貸出冊数が 5冊(上限)
And 書籍 "坊っちゃん" が貸出可能
When 鈴木に貸し出そうとする
Then "貸出上限に達しています" とエラーになる
And 貸出冊数は変わらない
大事な知識はドメインエキスパートの頭の中にしかなかったりするので、モデルを作るのは厳しい道だ。 とにかく質問をする以外に近道はない。これは発見というよりは共創のプロセスであるとみんなで認識しよう。
3. ドメインの複雑さと戦う
Bounded Contexts とモデル
ビジネスが大きくなると、同じ言葉がコンテキストによって違う意味を持つ場合がある。 あらゆる場所で使える巨大な言葉を定義したり、言葉にプレフィックスを付けたりする解決法もあるが、うまくいかないことが多い。
こういうときは Bounded Contexts を作る。
実は、モデル(=ユビキタス言語、つまり語彙や原理やビジネスルール)はBounded Contextの中にしか存在し得ない。 特定の問題を解決するために境界を作り、その目的を意識しながらドメインをモデル化することで、初めてモデルは意味を持つ。 境界がなければモデルはただの現実世界の完全コピーになってしまい、なんの役にもたたない代物になる。
「ユビキタス」とは、あくまで社内のあらゆる場所で使われるべきという意味であり、 どんなコンテキストでも使える「ユニバーサル」という意味ではないのだ。
Bounded Context が現実世界のどこに存在するかというと、ドメインエキスパートの心の中である(!)。
サブドメインとBounded Contexts
サブドメインは発見するものである。企業の戦略で決まり、開発者の範疇の外にある。
一方、Bounded Context(これはユビキタス言語というモデルのスコープでもある)は、**デザイン(設計)**するものである。 戦略的な判断により適切に決める。
Contextは必ずしもサブドメインと1-1で結びつかなくてもいい。 小さいシステムなら全てのサブドメインを一つのContextで対処することもできる。 もしモデルがコンフリクトするようであれば、コンテキストを分割していくべきだろう。 また、ある1つのサブドメインにおいて問題のバリエーションが多い場合は、 1つのサブドメインに対して複数のContextを同時に割り当てることもある。
Contextの範囲は小さいほうがモデルの一貫性を保ちやすく、管理しやしく、スケールアウトしやすい。 一方で、同じデータを扱うユースケースが複数のContextに分断されると効率が落ちる。
境界の役割
アーキテクチャデザイン(設計)とは、つまるところ境界を決めることである。 何が含まれ、何が除外され、何が境界を跨ぎ、何がその間を行き来するかを明らかにすることだ。
Bounded Context は物理境界である。 つまり、独自に進化が可能で、コード・デプロイ・バージョン管理などは完全に独立している。 service, subsystem といった手法で分離される。
Bounded Contextは 所有境界 でもある。 1つのチームだけで管理されるべきであり、2つ以上のチームで共有はできない。
Bounded Context の中に複数のサブドメインが含まれる場合、 それらサブドメイン間の境界は論理境界となる。 namespace, module, package といった手法で分離される。
4. Bounded Context を組み合わせる
複数のBounded Contextsの間でのコミュニケーションにおける、 取り決めや約束事を Contracts と呼ぶ。 そのスタイルにはいくつかの種類がある。
Cooperation
1つのチームが複数のContextを管理していたり、2つのチームが共通のゴールを共有しており、 密なコミュニケーションがとれる場合には、Cooperationが選択できる。
Partnershipは、APIに変更があったときにチーム間で連絡し合って、都度調整する方法。 素朴でシンプルだが、チーム間で極めて頻繁かつ同期的なコミュニケーションが必要である。
Shared Kernelは、複数のContextで同じモデルを使用すること。 コードが重複することの弊害が、コードを共有することで発生する調整コストよりも大きい場合に使用を検討する。 変更を加えたら自動でCIが実行され、即時に全てのContextに反映されるようにする。 影響を小さくするため、モデル化の範囲は必要最小限とすべき。 代表的なユースケースとしては以下がある。
- 地理的に離れたチームなのでPartnershipを採用できないとき
- レガシーシステムの近代化の過程での一時的な構成として
- 単一チームで複数のContextを扱うときに、あえて境界を明確化するため (単一チームでPartnershipを採用すると境界が曖昧になりがち)
Customer-Supplier
Upstream(supplier)とDownstream(customer)を定義し、 それぞれ独立して開発できるようにする方法。 パワーバランスに差があるのが特徴。
Comformistは、upstreamが決めた形式にdownstreamが適合(Conform)する方法。 外部サービスを利用する場合などに取るパターン。 upstream側が業界標準形式のデータを提供していたり、 洗練されたモデルを提供していて、それをそのまま使いたい場合に最適。
モデルをそのまま使うのではなく変換したい場合は、Anticorruption Layerを使う。 upstreamの頻繁な変更が直接影響しないようにすることで、downstreamのモデルを守るのが目的。 不要なものを削ったり、Contextで使いやすくしたりする。
逆に、upstream側で公開するデータの形式に気を配る方法をOpen-Host Serviceと呼ぶ。 モデルを独立して進化させつつ、downstreamへの影響を最小限に抑えるのが目的。 Anticorruption Layerの逆バージョンと言える。 モデルを直接公開せず、Published Languageと呼ばれる、customer向けに最適化された別の形式に変換して公開する。 Published Languageをバージョニングすることで、customerに対する破壊的変更をなくすことも可能。
Separate Ways
場合によっては協調しないという選択もある。
組織サイズや社内政治によりコミュニケーションが難しい場合は、 コードが重複したとしても、別々に開発したほうが効率的なこともある。
また、Generic Subdomainsについては既成のソリューションがあるので、 無理に自前でContextを作って協調コストを上げるよりも、 単にそれぞれのContextが外部サービスを直接利用するほうが効率的なこともある。 代表例は、認証認可、通知サービス、支払い、ストレージ、ログなど。
モデルが根本的に異なりすぎる場合も、あえて別々の道を歩む選択肢をとりうる。 適合どころか腐敗防止層を構築するコストすら高すぎる場合などだ。 こういうときはコードの重複を許容してでも独立性を保つ方が合理的な判断となることがある。
ただし、Core Subdomain に Separate Ways を採用するのはダメだ。 これをやってしまうと会社の成長の足を引っ張ることになる。
Context Map
コンテキスト間の関係性を図にしたもの。作ると以下のメリットがある。
- システムのコンポーネントとモデルを俯瞰できる
- 必要なコミュニケーションのパターンを知ることができる(密に連携が必要 or あまり連携は必要ない)
- 組織の問題を発見できる(なぜこのチームの利用者は全て腐敗防止層を作らなければいけない?意思疎通できてる?とか)
5. シンプルなロジックの実装手法
Transaction Script
単純に手続きを順に書いていくやり方。DBに直接アクセスできる。 必ず成功か失敗のいずれかの結果になる必要があり、中途半端な状態になってはいけない。 より洗練された書き方の基礎となる書き方である。
一番のメリットはそのシンプルさである。理解しやすくパフォーマンスも良い。 一方で、ロジックが複雑になるとすぐにコードが重複して管理が難しくなり、一貫性のない挙動を生みがち。
ETLのような、ロジックがシンプルなSupporting Domainに最適。 Core Domainに使われるべきではない。
Active Record
Active Recordと呼ばれるオブジェクトに、データ構造と、 そのデータ構造にアクセスするためのCRUDメソッドを持たせたうえで、処理を書いていく方法。 書き方は本質的にトランザクションスクリプトと同じ。
このパターンのメリットは、複雑なデータ構造をDBにマッピングする際の複雑さを隠蔽できることだ。 また、バリデーションなどのビジネスロジックを持たせられることだ。 なお、複数のエンティティに関する複雑なロジックは、Active Recordの外に書かれることが多い。
ロジックはシンプルだがデータ構造が複雑なものに最適といえる。例えば以下のようなものだ。
- CRUD処理しかしないSupporting Domain
- Generic Domainにおける外部サービスの組み込み
- モデルの相互変換処理
Activeとは能動的の意味で、Record自身がデータベース操作を実行するためによう呼ばれている。 必然的にORMなどと一体化している。 アンチパターンと言われることもあるが、適切な場所で使うのは全く問題ないどころか効果的である。
6. 複雑なロジックの実装手法
Domain Model Pattern
複雑なドメインに対してはドメインモデルパターンが利用できる。 (Event-sourced domain modelに対し、こちらはState-based domain modelと呼ぶこともある)
ドメインモデルとは、振る舞いとデータの両方を組み込んだモデルのこと。 ドメインモデルはプレーンなオブジェクトとして実装する。 また、インフラ層は分離して直接依存しない形で実装する。 なぜなら、ドメインはもともと複雑なので、本質でない部分により偶発的な複雑さを持ち込む余裕はないからだ。 また、技術詳細が隔離されることで、ドメイン層でユビキタス言語を喋らせるのが容易になるからだ。
ドメインモデルを組み上げるためのパーツとなるのが、値オブジェクト、集約、ドメインサービスの3つである。 (エンティティは集約の一部に過ぎないからあえてここに含めない)
構成要素
値オブジェクトは、値によってのみ識別される概念であり、IDフィールドを必要としない。 一つでもプロパティを変更するときは、新しいオブジェクトを返す(イミュータブルにする)。 stringやnumberなどの代わりに使うことで、コードの意図が伝わりやすくなる。 また、バリデーションを確実にシンプルに実行できる(ユビキタス言語を表現できる)。 使えそうな場所ではどんどん使おう。
エンティティは値オブジェクトと逆で、識別にIDが必要なもの。 例えば人は、名前だけでは識別できないよね。 変更が行われることが想定される、ミュータブルなオブジェクトである。 独立して実装されることはなく、必ず集約の一部として実装される。
集約は単一または複数のエンティティから構成される、一貫性を担保するための境界である。 集約の内部状態は、その公開APIを介してのみ変更が可能であり、常に正しい状態が保たれる。 公開APIはしばしばCommandsと呼ばれる。
集約では同時更新を防ぐため、必ずConcurrency Managementが必要。 例えば、更新時にバージョン番号が一致しているかを確認してから更新するなど。
集約はトランザクション境界でもある。 一貫性を保つためには一部だけが更新されることがあってはならないからだ。 さらに、1つのトランザクションで複数の集約を更新すべきではない。 そうしたくなるとしたら、それは集約の境界を間違って設計しているシグナルだ。
強い一貫性が必要なエンティティだけを同一の集約に含め、それ以外は別の集約として定義したうえで結果整合性で済ませる。 他の集約への参照は、オブジェクトそのものではなくIDで行う。 強い一貫性が必要かどうかは、「もしそのデータが実際よりも遅れていた場合に問題がおきるか?」で判断する。
集約のエンティティの階層を持ち、このうち1つだけが外部公開APIを持つ。 このエンティティをAggregate Rootと呼ぶ。
集約の名前、データ属性、メソッド、ドメインイベントなどは、全てユビキタス言語で命名されなければならない。 これは複雑なシステムを作成する際には極めて重要な指針である。
Domain Eventsとは、ビジネスドメインで発生したイベントを説明するメッセージである。
集約の外部インターフェースの一部であり、後段のコンポーネントが購読して次の処理を実行するきっかけとなる。
Ticked assigned
やMessage received
などの過去形で表現されたメッセージと、
理由、日時や処理したデータなど、後段で必要となるすべての情報で構成される。
Domain Servicesは、ビジネスロジックを持つステートレスで単純なオブジェクトだ。 複数の集約の読み取りをしたのちに何らかの計算や分析を行うロジックを記述する。 更新向きではなく、まして1つのトランザクションで複数の集約を更新することが特別に許された場所でもない。 なお、サービスという言葉とは裏腹に、サービスっぽいことはなにもしない。
なぜ複雑さを減らせるのか
システムの複雑さを決める要素の一つに、Degrees of freedom (自由度)がある。 これは、システムが「どれだけ多くの異なる状態になれるか」といえる。 選択肢が増えるほど、システムの予測や制御が困難になるからだ。
公開されている変数(データ)が多かったり、変数を複数の場所で直接書き換えたりしていると、自由度は高まる。 逆に、公開する変数を減らし、限られたメソッドを介して変更を行うと、自由度は低くなる。
集約や値オブジェクトは、不変条件(ビジネスルール)をカプセル化することで自由度を減らしている。 不変条件を手続き的にあちこちに書いていくと、次第に自由度が高まっていき、いずれ複雑さに対処できなくなる。
(Abstract Data Typeも、Repository Patternも、Domain Modelも、Fluxも、State Machineも、 どれも自由度を減らして複雑さと戦うための手法と言える)
7. 時間軸をモデル化する
Event sourcing
例えば「顧客」みたいなテーブルが存在したとする。 そのテーブルを見れば、今の顧客の情報はわかるが、今に至るまでの様々な変遷はわからない。 これをわかるようにする仕組みが Event sourcing だ。
Event sourcingでは、集約のメソッドで内部状態を変えるのではない。 様々なイベント群をすべて記録しておき、集約を使うたびに、毎回イベントから集約の状態を再構築する。 これにより、過去から現状に至るまでの、すべての変遷を知ることができる。 (その構築過程はFluxっぽいイメージになる)
全てのイベントが記録されているため、必要に応じてアドホックにモデルを用意できる。 例えば、検索用のモデル、分析用のモデルなど。
イベントを保存するDBをEvent storeとよぶ。 ここが情報の Source of truth になる。 イベントは追加のみ可能で、削除や更新はできない。 このパターンでは、強い一貫性が必要になるのは Event store だけである。 最小限の実装は以下のようになる。
interface EventStore {
/* あるエンティティに関するイベントを全て取得する */
fetch(instanceId: string): Promise<Event[]>
/* あるエンティティに関するイベントを追加する */
append(
instanceId: string,
newEvents: Event[],
expectedVersion: number, // 並行処理で変更が競合しないようにするための楽観ロック用
): Promise<void>
}
Event sourcingは新しい手法ではなく、例えば銀行業界のシステムなどでは古くから使われている手法である。
Event-Sourced Domain Model
Event-Sourced domain model では、State-based domain model とは処理の流れが異なる。 基本的な流れは以下のようになる。
- ある集約に関するイベント群を全て読み込む
- イベント群をもとに集約を再構築する
- 集約は全てのイベント群(e.g.
events
)と、イベント群から再構築した集約の状態(e.g.state
)を持つ
- 集約は全てのイベント群(e.g.
- 集約のメソッドを実行し、結果として新しいドメインイベントが生成され、
events
やstate
に反映される - 集約を永続化する(イベントを保存するだけで済む)
メリット
- タイムトラベルできるので、分析や最適化やデバッグするときに有利
- イベントを元にしてアドホックに色々な投影オブジェクトを作れるので、深い洞察を得やすい
- 監査対応が容易になる。特にお金にまつわるものに最適。
- より精密な並行性制御が可能になる。失敗するまでのイベントの流れを見れるので。
デメリット
- 学習コストが高い
- イベントのスキーマ変更をする難易度が高い(過去のイベントは書き換えられないので)
- そもそもアーキテクチャが複雑
FAQ
- パフォーマンスは問題にならないか?
- イベント数が10,000を超えると起きがちだが、普通は100を超えることはない
- 問題が起きたときにスナップショットパターンの導入を検討すると良い
- そもそも集約の境界設定が間違ってないか再確認したほうがいい
- スケールするのか?
- イベントストアは簡単にシャーディングできるので問題ない
- データの削除(GDPR対応)はどうやる?
- Eventに含まれるセンシティブな情報は暗号化しておき、秘密鍵を集約IDと紐づけて保存しておき、削除時には秘密鍵だけを消す
- テキストログに履歴を書くじゃダメなの?
- DBとログの一貫性が担保できない。例えばDBで失敗したときにログの一部をロールバックすることはできないでしょう。
- state-basedなモデルを使いつつ、同じトランザクション内でログテーブルに履歴を書くじゃダメなの?
- 一貫性の担保がやや弱い。開発時には常にログファイルへの書き込みを忘れないようにする必要があるが、難しいでしょう。
- state-basedなモデルを使いつつ、DBのトリガーで履歴テーブルに書き込むじゃダメなの?
- 前項の欠点は解消するが、そもそもデータに「why」であるビジネスコンテキストがないのであまり意味がない
8. アーキテクチャのパターン
ここまでは、モデルやビジネスロジックをコード化するための戦術を学んできた。 この章ではもう少し幅を広げ、システムコンポーネント間の関係を構築するための戦術、 つまりアーキテクチャを作り上げるための技を学んでみよう。
アーキテクチャとはつまり以下のような話だ。
ビジネスロジックが、システムの入出力やインフラとどのように関わるのか。 その境界をどのように設定するのか。 どういう情報をコンポーネント間でやり取りすべきなのか。 お互いを呼び合うときのやり方はどうするか。
Layered architecture
Presentation layer, Business logic layer, Data access layer の3層からなる。
Presentation layer (or User interface layer)は外界に公開された接点となるレイヤーで、入出力を担当する。 GUI, CUI, API, トピックの購読や発行など。同期・非同期の両方がある。
Business logic layer (or Domain layer)は、ソフトウエアの中核となる部分。 ビジネスロジックを含むレイヤー。
Data access layer (or Infrastructure layer)は、永続化を担当する。 Database、メッセージバス(内部利用のみの場合。外部公開されるものはプレゼン層に該当する)、 オブジェクトストレージ、翻訳などの外部APIサービスなどが含まれる。
依存の方向は Presentation -> Business logic -> Data access となる。
Presentation と Business の間にService layer(or Application layer)を設けることもある。 ドメインモデルやアクティブレコードを組み合わせてユースケースを構築したり、トランザクション管理をカプセル化したりする。 常に必要というわけではなく、例えばビジネス層が既にTransaction Scriptで書かれている場合は、 単に役割が重複するだけなので不要である。 サービスと言っても物理的に独立したサービスが動くわけではなく、単に同じシステム内でレイヤーを論理分割するだけ。 メリットは以下。
- 複数のインターフェース(REST, GraphQL, CLIなど)にコードを重複させることなく一貫した機能を提供できる
- 関連するメソッドをモジュール化して扱いやすくできる
- プレゼン層とビジネスロジック層がより疎結合になる
- ビジネスロジックのテストが簡単になる。
ビジネスロジックが Transaction script や Active Record pattern で書かれているものに最適。 Domain model だと、Domain modelは永続化層に依存してはならないので、この方法は不向き。より良い方法を後述する。
ちなみにLayerとTierの違いは、Layerはただの論理分割でありライフサイクルが同じ、Tierは物理分割でライフサイクルも別である点だ。
Ports & Adapters
Domain model pattern を実装するときの最大の課題は、 ビジネスロジックをいかなる技術的関心にも依存させないことである。 そのためのパターンが Ports & Adapters パターンである。 Hexagonal architecture や Onion architecture とも呼ばれる。
アーキテクチャの構成はこうだ。
まず、ドメインに関係のない技術的関心事は全て Infrastructure layer に置く。 データベース、外部サービス、UI、メッセージバスなどである。
そして、Layered architecture のように Business layer が直接 Infra layer を呼び出すのではなく、 Business layer は Port(規格・インターフェース) のみを定義し、Infra layer は Adapter(実装) を定義する。 これにより、依存の方向が Infra -> Business logic という逆方向になり、 Business layer が最も中心的な役割を果たすようになる。
これは、Dependency Inversion Principle(DIP) と呼ばれる、 「高レベルのモジュールは低レベルのモジュールに依存してはならない」という原則に基づいた構成である。
最後に、Business logic の手前にファサードとして Application layer を配置し、 ユースケースの調整を担当させる。 最終的な依存関係は Infra -> Application -> Business logic となる。
Command-Query Responsibility Segregation(CQRS)
CQRSは、ビジネスロジックと技術的な関心を分離するという点において Ports & Adapters と似ているが、 複数のモデルを使う Polyglot modeling である点が特徴的である。
CQRSは、しばしば複数のDBを起源とする、複数のモデルを使いたい場合に適している。 運用的視点でいうと、目的に応じたモデルをアドホックに作成したり、モデルを継続的に改善しやすい点がよい。 技術的視点でいうと、強みに応じて複数のDatabaseを使い分けられる点がよい。
また、もともとCQRSは、Event-sourced model において、 複数の集約にまたがるクエリができないという問題を解決するために生まれた。 このため、当然ながらEvent-sourced domain model に向いている。 CQRSを使えば、過去の全てのイベントを投影済みの最新の集約を物理DBに格納できるので、柔軟なクエリが可能になるのだ。
Command execution modelは更新用モデル。 ビジネスロジック、バリデーションルール、不変条件の強制などを担当する。 強い一貫性を必要とする唯一のモデルであり、Source of Truth である。 強い一貫性が保証された形でデータを読み出せる必要がある。 更新時にはConcurrency managementが必要である。
Read modelはクエリ用モデル。 DB上、ファイル上、メモリ上など、どこにでも住める。 あらかじめキャッシュされた、全てのイベントを投影済みのモデルである(RDBのmaterialized viewに近い)。 モデルはいつでもゼロから再生成できるので、投影済みのモデルを削除したり、 将来的に別のモデルを追加することも容易である。 モデルは用途に応じていくつ作ってもいい。 Read-only モデルであり、モデルの一部を書き換えることはできない。
Projection engine の動作には同期と非同期がある。 Synchronous Projectionの場合は、以下の流れになる。
- Projection engine は Online Transaction Processing (OLTP/更新) 用のDBに対し、指定したチェックポイント以降に追加または更新されたレコードをクエリする
- Projection engine は、そのデータをもとに Read model を再生成 or 更新する
- Projection engine は、処理済みの最終レコードのチェックポイントを覚えておき、次回以降のクエリ時にその値を指定する
前提として、OTLPのレコードはチェックポイント番号を保持し、
チェックポイント番号をもとに増分レコードだけをクエリできる仕組みになっている必要がある。
なお、投影モデルをゼロから再生成したいときは、単にチェックポイントを0
などにリセットするだけでよい。
Asynchronous Projectionでは、Command execution model が全ての変更をメッセージバスに発行する。 Projection engine は、メッセージバスからイベントを受信し、リードモデルを更新する。 並列化が可能でスケールアウトが容易になる一方で、 処理順が入れ替わったり重複実行したときには、一貫性を失ったデータが生成される恐れがある。 また、新しい投影モデルを追加したり、既存の投影モデルをゼロから再生成するのは困難が伴う。
このため、まずは同期で構築し、必要に応じて部分的に非同期にするのがおすすめである。
よくある誤解として、Command execution model はデータを返してはならない、というものがあるが、間違い。 例えばエラー時などは、コンテキストをデータとして返してもOK。 ただし、このときに使うデータは強い一貫性が必要なので、Command execution modelのデータを使うこと。 結果整合性しか持たない Read model のデータを使うことはできない。
アーキテクチャを選択する単位
アーキテクチャは、コンテキスト単位ではなくサブドメイン単位で選択すること。 同じコンテキスト内では必ず同じアーキテクチャを使うというルールにしてしまうと、 意図しない新たな水平分割を作ってしまい、Big ball of Mud になってしまいがちだから。 代わりに、サブドメインごとに垂直に分けておくとよい。 そうすれば、あとでコンテキストを分離抽出することもやりやすいから。
9. コミュニケーションの取り方
複数のコンテキスト間でのコミュニケーションの取り方を解説する。
モデルの変換
コンテキストはモデル(ユビキタス言語)の境界である。 Partnership や Shared-kernel パターンを取れる場合は都度修正すればいいが、 customer-supplierパターンを取る場合はモデルの変換を行う必要がある。
変換は、Anticorruption layer(ACL)を使ってdownstream側で行われる場合と、 Open-host service(OHS)を使ってupstream側で行われる場合があるが、 場所が違うだけでやることは概ね同じである。
ステートレスかつ同期的なモデル変換
リクエストが発生する都度にOHSかACLにおいて変換が行われるタイプのものを、ステートレスな変換と呼ぶ。 このうち、同期的なものについては、Proxy design pattern を使うことになる。
なお、システムの規模などによっては、APIゲートウェイパターンなどを使って 変換処理をオフロードしたほうがより良い場合がある。
ステートレスかつ非同期的なモデル変換
非同期のステートレス変換では、Message Proxyを使う。 これはソースコンテキストとターゲットコンテキストの間に配置される。 Event-sourced + Open-host serviceの組み合わせでは、この非同期変換が欠かせない。 なぜなら、Event-sourced domain model をそのまま公開すると、内部イベントまで全て外部に露出してしまうからである。 代わりに、Published languageへの変換を行ってから公開せよ。 そうすることで、コンテキスト間の境界を明確に保ち、内部詳細を隠蔽できる。
ステートフルなモデル変換
複数の情報源やコンテキストから情報を集めなければならない場合や、バッチで効率よくデータを取得したい場合がある。 この場合は、API gatewayでは実装できず、独自のストレージを持つステートフルなサービスとして実装が必要となる。 マネージドサービスもあるので活用されたい。
ユースケースとしては、BFFとしての利用(upstream側)や、 外部の複数コンテキストを便利に使うために単一のACLでまとめて変換してから受け取りたい場合(downstream側)などだ。
集約どうしの連携
Outbox pattern
ある集約がシステムの他の部分と連携する方法の一つが、イベントの発行であると学んだ。 では、そのイベントはどういうやり方で発行すべきだろうか。 集約自体やユースケースにおいてイベントを発行すると、 一部が失敗した時などに不整合が残ったままになるので、一貫性のある動作は実現できない。
かわりに、Outbox patternを使う。動作の流れは以下のとおり。 この方法では、仕組み上、イベントは最低1回は発行が保証され、場合によっては2回以上発行される可能性がある。
- 集約の状態更新とイベントの発行(≒outbox tableの追記)を同一トランザクション内で行う
- 煩雑さを減らすため、DB固有の機能でダブルライトするといいよ
- Message relayがDBから新しいイベントを取得し、メッセージバスに送る
- 未発行のイベントの検出には2つの方法がある
- relayがpollingする方法
- DBがpushでrelayをキックする方法
- 未発行のイベントの検出には2つの方法がある
- 成功したらMessage relayがOutbox tableに
published
マークを付けるか、行ごと消す
Saga pattern
1つのトランザクションでは1つの集約しか更新しないのは、 適切な境界設定を促すという意味で、非常に重要な原則である。 一方で、複数の集約を更新しないといけない場面がどうしてもある。 そういうときはsagaを使う。
Sagaは、複数の要素にまたがる長期実行されるトランザクションを管理するためのパターン。 「長期」というのは時間のことではなく、どちらかというとトランザクションの観点である。 集約に限らず、イベントを発行したり受け取ったりする全てのコンポーネントから利用できる。
sagaの動作は、必要なイベントを購読し、それを受け取ったら適切なコマンドを実行するのが基本。 もし処理が失敗したときは失敗イベントが流れてくるので、関連データをロールバックするためのコマンドを発行する。 これを 補償アクション(compensating action) という。
実装方法としては、saga関連のイベント履歴を残さない方法と残す方法で大きく分かれる。 シンプルであれば履歴を残さないでもよいが、なぜ失敗したのかを追うのは難しくなる。 残す場合は、sagaを一つの独立した event-sourced aggregate として実装して使う。 イベントの情報にのみ依存するため、インスタンス化は暗黙的に行われる。 また、Outbox pattern を使ったうえで、実際の業務処理をsagaの外(集約やユースケース)に担わせることにより、 sagaがトランザクションの管理に専念でき、どの時点で失敗しても一貫性が保たれるようにする。
sagaを使ったとしても、一貫性は集約の単位でしか保証されない点に気をつける。 集約の外にあるものは全て、結果整合性しか担保されていない。
Process Manager
sagaはイベントと対応するコマンドが1対1で結びつくものに最適。 一方、処理分岐があるような複雑なロジックがある場合は、Process Managerを使う。 state-based or event-sourced な集約として実装する。 sagaと違い、イベントだけではなく自身が持つ詳細な状態に依存するため、明示的なインスタンス化が必要。また、インスタンスはIDを持つ。 より広義のビジネスプロセス調整に主眼を置いたもの。 実装の仕方は saga とほぼ同じ。
10. 設計の経験則 (Design Heuristics)
Heuristics is a way of solving problems by discovering things yourself and learning from your own experiences. - Cambridge Dictionary
この章では、設計、つまり「ビジネスドメインでの発見を技術的な実装手法にどう結びつけるか」について考える。 よりよい設計の知見は、Heuristics(経験則 = 自らの探索と実践学習に基づく問題解決方法) として得られるものである。 つまり、全員に成功が保証された手法ではないものの、たいていの場合にはうまくいくであろう方法である。
設計方針を決めるためには、まずは subdomain のタイプ(core/generic/supporting)を発見するのが最も大事である。 実装技術はサブドメイン単位で選択する。タイプに応じて、必要最小限の最もシンプルな方法を選択するとよい。 無条件で全てのサブドメインにCQRSを適用するみたいなことは、無駄だし非効率なのでやめよう。
Bounded Contextの分け方
Bounded Contextを間違った単位で作ったときの弊害は大きく、修正も困難である。 まずは、Core subdomain とその関連subdomainを一つのContextに含める ことから始めると良い。 全貌が分かってきてから、別のコンテキストに切り出すこと(物理分割)は容易だし、 subdomain(論理分割)の再構成はもっと容易だ。
ビジネスロジックの実装手法の選択
大きな方針は以下の通り。
- Subdomain が、お金を扱うものだったり、データの深い分析や監査ログが必要なら、Event-sourced domain model
- そうではない場合で、Subdomain のドメイン言語やビジネスロジックが複雑なら、State-based domain model
- そうではない場合で、Subdomain のデータ構造が複雑なら、Active record pattern
- そうではない場合は、Transaction script
アーキテクチャの選択
- Event-sourced domain model ならCQRS を採用する。
- じゃないとまともにクエリができないので。
- State-based domain model なら Ports & Adaptersパターンを採用する。
- 複雑なロジックをもつドメインオブジェクトから、少なくとも永続化のことは分離してシンプルにしたいから。
- Active record pattern なら Layered architecture(4-layers) を採用する。
- Presentation -> Application -> Active Record -> Infrastructure のイメージ
- 複雑なロジックはサービスレイヤーに書く
- Transaction script なら Layered architecture(3-layers) を採用する。
- Data access layer が振る舞いを持たないモデル(データモデル)を返したり受け取ったりする
- モデルを定義する場所は Data access layerだろう
- Table Data Gateway Patternなど(Repositoryパターンをテーブルと密結合にしたもの)が有名っぽい
- Business layerにロジックを書く (重複上等)
- Data access layer が振る舞いを持たないモデル(データモデル)を返したり受け取ったりする
なお、アーキテクチャについてはリクルートの資料が参考になる。
テスト戦略の選択
Domain model patternなら Testing Pyramid が最適。 ドメイン層にたくさんユニットテストを書いていくことが堅牢さにつながるからだ。
Active Record patternなら Testing Diamond が最適。 サービス層とビジネスロジック層にロジックが散らばりがちなので、ここをまとめて効率よくテストを書けるからだ。
Transaction scriptなら Reversed Testing Pyramid が最適。 コードがミニマルなので、全部まとめてテストするほうが効率がいいからだ。
その他
ユビキタス言語が一番大事なので、ここをおろそかにしないこと。コストは後で十分回収できる。 SubdomainがCore/Genric/Supportのどれであっても。
11. デザインを進化させる
ドメインタイプの変化
サブドメインのタイプ(core/generic/supporting)は、ビジネスの変化とともに変わっていくことがある。
- Core -> Generic (Commoditization)
- 自社で作っていた競争力のある機能が、他社のより安価で優れたパブリックなサービスとして利用可能になったので乗り換える場合など
- Generic -> Core (Opportunity)
- 既成サービスが使い物にならなくなったので自社で開発することにした場合など(e.g. AWS)
- Supporting -> Generic (Commoditization)
- 簡単な機能だけど既製サービスがないから自前で作っていた領域に、既製サービスが出たので乗り換える場合など
- Supporting -> Core (Opportunity)
- その領域で利益を上げることが可能になった場合
- モデルを変化に対応させるのが難しくなった場合
- 高い頻度で更新されるようになった場合
- (とはいえ、複雑になったけど利益が上がっていないなら、コードを捨ててSupportingにとどまるべき)
- Core -> Supporting (Simplification)
- コアドメインと思っていたけど、複雑なだけで利益を産まないと分かったときなど
- こういうときは、他のサブドメインから必要とされる最低限の機能だけ残して、残りは捨てよう
- Generic -> Supporting (Reducing integration costs)
- 既成サービスを使っていたけど、コストが高すぎるので自社で開発することにした場合など
戦略的設計を進化させる
サブドメインのタイプが変わると、戦略も変わってくる。
新たにコアドメインになった場合、変化が激しくなるので、ACLで内部モデルを守り、OHSでConsumerを守る必要がある。 また、それまでSeparated-wayでやっていたのなら、Customer-Supplier pattern などに切り替える必要がある (なお、コアドメインなので1チームで管理する必要があり、Shared-kernelは使えない)。 また、非コアドメインなら外注したり弱いエンジニアに任せることができるが、コアになったなら熟練者による内製に移る必要がある。
戦術的設計を進化させる
ビジネス要求に対応することが技術的に辛くなってきたときは、ドメインの種類が変化しているサインである。
変化が起きるのは避けられないことだし、恐れる必要もないし、自然なことだ。 複雑化をあらかじめ予見して重厚な設計を全ての領域に適用することは、無駄で非効率で不可能なので、 必要になったときに、設計を進化させれば良い。
- Transaction Script -> Active Record
- データ構造が複雑になりすぎて扱いが困難になってきたとき
- DBに直接アクセスするのではなく、レコードオブジェクトを介してアクセスすることで、データマッピングの複雑さを隠蔽していく
- Active Record -> Domain Model
- コードの重複と、動作の一貫性の欠如が目立ってきたとき
- モデルにデータだけを持たせるだけでなく、振る舞いも持たせていく
- Domain Model -> Event-sourced Domain Model
- 集約の境界がきちんと設計されていれば、イベントベースへの移行が可能
- 移行で一番大変になるのは、過去のイベントをどう作るか
- ベストエフォートでイベントを手作りするか、諦めて「マイグレーションイベント」でお茶を濁すかの二択
組織の変化に対応する
例えばコンテキストを2つに分割したい場合、1つのコンテキストは単一チームで管理すべきなので、 チームを2つに分ける必要がある。組織が変化すると、コンテキスト間の関係性も変える必要がある。
- Partnership -> Customer-Supplier
- あるコンテキストを開発するチームが時差のある場所に移ったことで、コミュニケーションが難しくなった場合など
- Customer-Supplier -> Separated Ways
- 物理的な距離や社内政治の問題でコミュニケーションに問題が発生し、別れたほうがうまくいきそうな場合など
ドメイン知識の深化に対応する
初期はシンプルに見えたものでも、ドメイン知識が深まるにつて新たなビジネスルールとエッジケースが次々と増えていきがち。 Bounded Contextを誤った境界で分けたときのコストはとても高いので、 ドメイン知識が浅いうちは境界の範囲を広く取る のが良い。 知識の発見が落ち着いた段階で、コンテキストを分けてマイクロサービス化などを検討すればよい。
成長に対応する
下記の通り、多くのDDDの道具は境界を設定することで定まる。
- Subdomain により Business building blocks が決まる
- Bounded Context により Model が決まる
- Aggregate により Consistency が決まる
- Value Object により Immutability が決まる
一方で、成長はコンポーネント間の境界をあいまいにして吹き飛ばしていく性質がある。 このため、成長にともう変化が当初の設計を壊していないか、または設計の更新が必要でないかを常に見直す必要がある。 そうしないと、ソフトウェアが Big ball of mud になってしまう。
つまり、成長に伴って増していく複雑さと戦うには、 設計によりもたらされる偶発的な複雑さを取り除く ことが重要だ。 一方で、本質的な複雑さは、DDDの道具と実践で管理せよ。
以下、設計を見直すための具体的な方法を紹介する。
Subdomain の見直し
はじめは1つのサブドメインだったものが、機能追加に伴い複数の関心事を持つようになることがある。 そういうときはドメインを再び研究し、同じデータセットを扱うユースケース群を洗い出して、 サブドメインの分割点を定期的に見直すことが大事。 もし異なるタイプのサブドメインを抽出できれば、より適切な技術戦略を個別に選択することができる。 また、コアドメインをコンパクトに蒸留できれば、シンプルさを保つことにもつながる。
Bounded Context の見直し
Bounded Contextは、特定の問題に焦点を当てるために設定された境界だ。 成長に伴い、その焦点がボケてきて複数の問題に手を付けたりするようになる("Jack of all trades, master of none"になる)。 これは偶発的な複雑さなので、 焦点をはっきりさせるために別のコンテキストに抽出 できないか検討しよう。
逆に、本来は1つのコンテキストであるべきだったことが徐々に分かってくる場合もある(複数のContextがChattyな関係になる)。 そういうときは、Contextが自律的に動作できるようにするため、コンテキストをマージする ことを検討しよう。
集約の見直し
集約の原則は「強い一貫性が必要な最小限の範囲で境界を設定する」である。
成長に伴い、必ずしも一貫性が必要ない範囲までどんどん集約が肥大化しがち。 こういうときは 適切に集約を分割 しよう。 もとの集約がシンプルになるだけではなく、 まだ見ぬ新たなBounded Contextに所属すべき 隠れたモデルを発見できることもある。
12. イベントストーミング
イベントストーミングは複数人でブレインストーミングして、ドメイン知識をすばやく発見・共有するための活動。 本質は 全てのステークホルダー間の目線合わせと知識の交配 である。
成果物はおまけみたいなものではあるが、Event-sourced domain model の Bounded Context、Aggregates, Domain Eventsのたたき台として使えるものになる。 もちろん、その道を行くのかどうかはビジネス領域の特性によって判断する。
必要なもの
- モデリングのための場所 (十分に大きなホワイトボードなど)
- 大量の色つき付箋とマーカー
- おやつと飲み物 数時間かかるからね
- 広い部屋 (移動を遮る椅子や机は外に出す)
手順
- Step1. ドメインイベントを探す
- オレンジの付箋に書く
- ドメインイベントとは、ビジネスにおける気になるイベントのことで、常に過去形で表現する
- 重複や順序は気にせずとにかく書く
- 新しいイベントを思いつくスピードが遅くなるまでやる
- Step2. 時系列に並べる
- まずはハッピーパス、次にそれ以外のパスをつくる。パスの分岐はマーカーで矢印で表現する。
- 重複の削除、正しくないイベントの修正、足りないイベントの追加などもここでやる
- Step3. 辛みポイントを探す
- ピンクの付箋で書いて45度傾けてダイヤ型に貼る
- 辛みポイントとは、ボトルネックになっている箇所、自動化可能な手作業がある箇所、ドキュメントのない箇所、ドメイン知識のない箇所、などだ
- 非効率な部分(儲けの種)を明示し、いつでも戻ってこれるようにするための作業
- Step4. 節目イベント(Pivotal Event)を探す
- 節目イベントとは、それまでのコンテキストや段階を大きく変えるイベントのこと
- e.g. ショッピングカートが初期化された、オーダーが送信された、オーダーが発送された、オーダーが配達された、など
- 節目イベントの上下に境界線をマーカーで入れる
- Bounded Contextを分ける境界の候補になる
- Step5. コマンドとアクターを探す
- コマンドとは、イベントやイベントの流れを引き起こす要因のことで、命令形で表現される
- コマンドは青い付箋で書き、対象のイベントの前に置く
- もしコマンドがアクターにより引き起こされる場合は、アクターの情報を小さな黄色の付箋に書いて、コマンドに貼る
- アクターとは、ビジネス領域のユーザーペルソナのこと
- e.g. Customer, Administrator, Editor, etc.
- Step6. ポリシーを探す
- アクターが引き起こすわけではないコマンドがあるなら、 Automation policies / 自動化ポリシー を探す
- ポリシーは「○○というイベントが起きたら、自動的に△△する」というルールのこと
- 発動条件があるならそれも付箋に書く
- 紫の付箋に書いて、イベントとコマンドの間に重ねて貼るか、線で結ぶ
- Step7. リードモデルを探す
- リードモデルとは、アクターがコマンドを実行するために必要な情報のこと
- システムの画面・帳表・通知などの候補になる
- 緑の付箋で書いてコマンドの前に貼る
- Step8. 外部システムを探す
- 探索しているドメインの外にあるシステム
- ピンクの付箋で書いて、以下の感じで貼る
- 入力の場合: 外部システム -> コマンド
- 出力の場合: イベント -> ポリシー -> 外部システム
- この時点で、全てのコマンドはアクター、ポリシー、外部システムのいずれかに発行される状態になるはず
- Step9. 集約を探す
- 似ている概念を集約にまとめられないか考える
- 集約は、コマンドを受取り、イベントを発行する
- 大きな黄色い付箋で表し、その左側にコマンドを、右側にイベントをまとめる
- Step10. Bounded Contextを探す
- 集約をグルーピングできる境界を探す
- 機能が似ているとか、ポリシーを共有しているなどがヒントになる
用途
- ユビキタス言語を作りたいとき
- ビジネスプロセスをモデル化したいとき
- 新機能の実装時の目線合わせと要件漏れの発見をしたいとき
- ドメイン知識を思い出したいとき(旧システムの更新時など)
- 既存のビジネスプロセスを改善する方法を見つけたいとき
- New hireのオンボーディングをしたいとき
- (逆に、シンプルなものや分かりきったもの、例えばシーケンシャルに処理が進むようなビジネスプロセスには向いていない)
コツ
イベントストーミングは厳格なルールではなく、ガイドにすぎないととらえて気軽にやってみよう。
まずはビジネス全体についてStep1-4までを実施して俯瞰し、 その後に各領域について個別イベントストーミングの全工程をやってみるのがよいだろう。
慣れていないメンバーで取り組むときには、 付箋の使い方や線の引き方などをまとめた凡例を何処かに貼っておくとわかりやすい。 リモートはあまりおすすめできないが、5人くらいまでなら意外といける。 場の熱が大事なので、冷めてきたと感じたら、新たな質問をして再び火を付けるか、 意見が出尽くして次のステップに進む段階になったのか、判断しよう。
13. 現実世界でのDDD
DDDについてよくある誤解がある。 「新規プロジェクトでしか使えない」「チーム全員がDDDを熟知していないと導入できない」 「すべての手法を一度に取り入れなければならない」などである。
実際はそんなことはない。既存のカオスなプロジェクトに、 DDDの初心者が部分的に手法を取り入れることは可能だし、むしろ効果的だ。
まずは分析する
分析で得られた情報は設計をモダン化する手がかりになる。
ビジネスドメインについて理解するため にDDDを使うことができる。 誰が客で、どんなサービスや価値を提供し、競争力の源はどこにあるか、などだ。 ドメイン全体を以下に分類すると、どこに注力すべきか見えてくる。 全てを分類するのは大変だし不可能なので、特にソフトウェアに関わる領域に注目しよう。
- Core subdomain
- 競合が持っていない「秘密のソース」を持つ領域
- 強みは技術であるとは限らず、人材やデザイン力などであることもある
- Generic subdomain
- サブスクやOSSの利用をしている領域
- Supporting subdomain
- CoreでもGenericでもない領域
現状の設計を理解するため にDDDを使うこともできる。 まずは最もハイレベルなコンポーネント構成を把握しよう。 つまり、独自に進化・テスト・デプロイしているまとまりを見つける。
そこにどんな Subdomain が含まれていて、ビジネスロジックの実装技法やアーキテクチャはどうなっているか見てみよう。 もっと高度な設計パターンが必要な箇所はないか? 逆に、簡素な設計パターンに移行できる箇所や、外部サービス利用に切り替えできる箇所はないか?
コンポーネントの関係性を把握してコンテキストマップを作り、問題点を探そう。 複数チームで1つのコンポーネントを管理していないか、Core subdomain のロジックが重複して書かれていないか、 Core subdomainのコードを外注していないか、変なモデルが外部から侵入してきていないか、など。
そしてモダン化する
戦略のモダン化
(以下は、間違ったBounded Contextの分離はリスクが高いことをよく理解したうえでやること)
まずは サブドメインごとに境界が明確になっているか 確認しておこう。 少なくとも namespace, modules, packages などの方法で論理分割されている状態にしておく。
その後、物理分割を行う。 複数チームが同じコードを触っているなら、Contextを分割したほうがいい。 また、ほぼ同じだけど役割が微妙に違うモデルが2つあるなら、片方のモデルは新しいコンテキストを作って移動させ、競合をなくそう。
コンテキストが必要最小限の数に落ち着いたら、次に関係性を棚卸ししていく。
コンテキストを分けたなら、PartnershipやShared-kernelではなく、 Customer-Supplierのいずれかの関係性に移行する必要がある。 レガシーシステムや更新頻度の高いupstreamからモデルを守れていないなら、ACLを導入する。 コンテキストの変更が多くの別のコンテキストに波及して大変なときは、OHSを導入する。 組織が大きくコミュニケーションに難があり、重要なドメインでもないのなら、Separated way に移行する。
戦術のモダン化
戦術のモダン化でやるべきは、ビジネス価値と実装手法がマッチしていないツラミポイントを探すことだ。 例えば Core subdomain なのに Transaction Script で実装しているなど。 こういう場合は、もっと高度な手法を取り入れる必要がある。
ユビキタス言語を磨いておく
モダン化の成功の秘訣は、ドメイン知識とモデルを持っていることだ。 そして、優れたモデルに必要なのはユビキタス言語だ。 既存コードは古すぎてもはや誰も何もわからないのだから、 イベントストーミングをやってユビキタス言語を復活させ、 ドメイン知識とモデルを再構築しよう。
モダン化の2パターン
システムをゼロから書き直すのは多くの場合失敗する。 大きく考えて小さく進めよう。
ストラングラーパターン という段階的に移行するための手法がある。 まずはじめにファサードを介して旧システム(Bounded Context)にアクセスするようにし、 ファサードの向こう側で徐々に新システムへ移行し、 最終的にファサードも旧システムもなくして新システムに完全に置き換える。 なお、移行を容易にするため、1コンテキスト1DBの原則はここでは例外としてよい。
徐々にリファクタリングするパターン もある。 この手法では、小さなステップを心がけること。 いきなりEvent-soucedにする前に、State-basedをはさむとか。 ドメインオブジェクトを一度に完璧に作らなくても、値オブジェクトを少しずつ足していくとか。 リファクタ時には、必要に応じて、新しいモデルをACLで守り、OHSで古いコードへの互換性を確保しよう。
実践的なDDD
DDDはオール・オア・ナッシングではない。 全部の道具を使わないといけないなんてことはない。 戦術パターンが肌に合わないなら、あなたが効率的だと思う別の戦術パターンを使っても全然OK。
大事なのは、ビジネスドメインとビジネス戦略を分析し、 問題解決のための効率的なモデルを見つけ出し、 ビジネス領域で必要とされていることに基づいてデザインを決定することだ。
DDDとは以下のことなのだ。
Your business domain drive software design decisions.
こっそりDDDを使う
組織にDDDへの理解がなければ、なかなか進めづらいこともある。 そんなときでも、自分だけこっそり手法を取り入れることができる。
例えば ユビキタス言語はすぐに取り入れられる。 ビジネスサイドの人間をコーヒーに誘い、使う言葉をよく聞こう。 技術から離れた言葉で会話したり、コードを書いたりする。 言葉のブレを見つけたら有識者に質問して、確定させる。 そうやって少しずつ組織に浸透させる。
また、 Bounded Context の考え方は、あらゆる問題を分割するときに役立つ。
- なぜ、単一のモデルではなく、特定の問題に特化したモデルを作るかというと、All-in-oneの解決法が役に立つことなどないからだ
- なぜ、ひとつのコンテキスト内に、似ているけど微妙に違う複数のモデルを持てないかというと、認知負荷が上がったり、解決法が複雑になったりするからだ
- なぜ、ひとつのコードベースを複数チームで触るのがだめかというと、チーム間の摩擦が起きたりコラボが妨げられるからだ
技術について議論するときは、理屈で訴えよう。
- なぜトランザクション境界が重要かというと、データの一貫性を守るためだ
- なぜ1つのトランザクションで複数の集約を更新してはいけないかというと、一貫性の境界が正しく設定されていることを担保するためだ
- なぜ集約を外部コンポーネントから直接編集してはいけないかというと、状態を変更するロジックを一箇所にまとめて重複がない状態を保つためだ
- なぜ集約の機能をストアドプロシージャに書いてはいけないかというと、論理的・物理的に離れた場所にロジックがあると、同期が取れずにデータが腐りがちだからだ
- なぜ集約の境界を小さく保つ必要があるかというと、広い境界設定は複雑さを上げるし、パフォーマンスにも悪影響を与えるからだ
- なぜログファイルではなくイベントソーシングかというと、前者では長期に渡ってデータ一貫性を保つことができないからだ
14. マイクロサービス
マイクロサービスの適切な粒度
OASISの定義によると、サービスとは、パブリックなインターフェースを通じて、特定の機能を提供する仕組みのこと。 パブリックなインターフェースはときに正面玄関と形容され、サービスにデータを入出力するすべての仕組みを指す。
マイクロサービスとは、マイクロな正面玄関を持つサービスのこと。 玄関がマイクロであることで、サービス利用者にとって理解と活用が容易になり、 自由度(degrees of freedom)も下がるので自律性やスケーラビリティを高めることができる。
しかし、正面玄関を狭くすることだけに着目してサービスを細かく分けていくと、 あらゆるイベントをサービス間で相互に購読する Distributed Big ball of mud 状態になる。 これは、正面玄関はマイクロになったけど、スタッフ専用入口が作られまくった状態といえる。
横軸にサービス粒度をとり、縦軸にLocal complexity (microservice単体の複雑さ≒変更コスト)と Global complexity(サービス全体の複雑さ≒変更コスト)をとると、需給曲線のようにグラフ上で交錯する。 大事なのはバランスである。
また、モジュールの理屈でいうと、狭いインターフェースかつ複雑な実装を持つDeep modulesが良い。 細かく分けるにつれて、インターフェースは狭いが実装も浅いSharrow modulesになっていく。 大事なのはバランスである。
DDDとマイクロサービスの境界
Bounded Contextはモデルの境界、Subdomainはビジネス能力の境界、集約は一貫性の境界である。 これらがマイクロサービスの粒度とどう関わるか。
Bounded Contextは、モノリスとして作る場合の最大範囲の境界を表す。 マイクロサービスは必ずBounded Contextだが、逆は成り立たない。 場合によっては1つのサービスにまとめることもある(訳注:これ意味がよくわからなかった)
BBoM < Bonded Context < (この間が適切な粒度) < Micro Service < Distributed BBoM
Aggregate が複数のサービスにまたがると、ろくな結果にならないので、 関連するAggregate群は一つのサービスに収まるべきだ。 なお、Aggregate単位でサービスを分けることも場合によってはありえるが、 多くの場合はGlobal Complexityが増すので適さない。
Subdomain単位でサービスを分けるのは、比較的うまくいく経験則だ。 「(どうやるかではなく)何をするか」の観点のまとまりなので、自然とDeep Modulesになるからだ。
15. Event-Driven Architecture
EDAとは
マイクロサービスでは event-driven architecture (EDA) が一般的に採用される。 EDAは使い方を間違えると distributed big ball of mud を生む。 この章ではEDAとDDDの関係について学ぶ。
EDAとは、他のコンポーネントのAPIを同期的に呼ぶのではなく、 変化の内容をイベントにより非同期で他のコンポーネントに伝達するアーキテクチャのこと。
イベントとは
一口にイベントといっても、DDDとEDAでは概念が異なる。 DDDのイベントは、集約の状態変遷を表現したもので、サービス内の変化を管理するために使う。 EDAのイベントは、複数のサービス間のコミュニケーションに使う。
イベントとコマンドはどちらもメッセージパターンの構成要素である。 いずれも非同期でメッセージを交換するが、性質が違う。
イベントは、既に起こったことを表す。拒否はできず、取り消したいなら保証アクションを発行する。 過去形で表現される。
コマンドは、指示を表す。拒否ができる。命令形で表現される。
イベントには、event notification, event-carried state transfer(ECST), domain events の3種類がある。
Event notificationは、ビジネスドメインにおける重要な変化の通知のこと。
イベントには最小限の情報(例えばエンティティのid)しか含まれない。
詳細な情報はあえて後からクエリさせることで、セキュリティと一貫性を保証するためだ。
システム間連携のために使われる。
イベントデータ例としては、結婚した personId: 123
など。
Event-carried state transfer (ECST) は、メッセージベースでデータをレプリケーションするための仕組みのこと。
イベントにはレプリケーションデータを生成するために必要となる全ての情報を含める(もし既に全体を通知済みなら、変更差分だけでもいいが)。
イベントデータ例としては、姓が変更された newLastName: 'Smith'
など。
このとおり、データそのものを伝えており、結婚したか離婚したかというビジネス変化は関心の範囲外である。
Domain eventは、ビジネスドメインにおける変化を表すメッセージのこと。
イベントには必要な情報を全て含める。
つまり、Event notificationとECSTの中間の性質と言える。
ビジネスドメインをモデル化したり説明するために使われる。
後述の通り、システム間連携にはあまり適さない。
イベントデータ例としては、結婚した personId: 123, assumePartnerLastName: true
など。
EDAの経験則
常に最悪を念頭において設計しよう。
- ネットワークは遅延する
- サーバーは一番落ちてほしくないときに落ちる
- イベントは順不同で届く
- イベントは重複して届く
- そしてこれらの不調は祝日に起こる
Event-driven architectureではイベントが正しく届かないと死ぬため、 確実にイベントを発行できるよう、以下を遵守する。
- Outbox パターンを使って確実にメッセージを発行する
- 購読者がイベントを重複排除したり並べ替えができるように、発行者はイベントの作り方を配慮をする
- Bounded Contextをまたいだ処理には補償アクションが必要となるため、sagaやprosess manager パターンを使う
Domain eventをそのまま露出すると、ロジックが漏れ出したり、コンテキスト同士が密結合になったり、 利用側でモデルに投影する負担が大きくなって煩雑になったりするので、できるだけ控えよう。 代わりに、Open-host service + Published language(イベント投影済みのデータ)、つまりECSTを使い、 Consumerが求める最小限の情報を提供しよう。 それが不都合なら、少なくとも露出するDomain eventの種類を限定しよう。
Bounded Context間のやり取りにおける一貫性の要件も、イベント選択のヒントになる。 結果整合性ではだめで最新データが必要ならば、Event notification mesageの採用が必須となる。 結果整合性でかまわないのであれば、ECST massage が候補になる。
16. Data Mesh
分析用データモデルとトランザクション用データモデル
Data Meshは、分析用のデータを扱うためのアーキテクチャである。
日々の業務で使うモデルをOnline Transactional Processing (OLTP) dataという。 OTLPは、ビジネスドメインのエンティティで、リアルタイムのビジネストランザクションのために最適化されている。
一方で、分析に用いるモデルをOnline Analytical Processing (OLAP) dataという。
OLAPは、ビジネス活動のパフォーマンスを分析したり、より大きな価値を創出する最適化を可能にするためのもの。 個々のビジネスエンティティは無視し、活動にのみ注目する。 そのために使うのが、fact table と dimension table である。
Factとは、既に起こったビジネス活動(動詞)のこと。
Fact Table はその記録である。追記のみ可能。変更は新しいデータの追加で行う。
動詞で表現される記録という点においてDomain eventと似ているが、現在形の表現も含まれる点が異なる。
なので、fact_solved_cases
だけでなくfact_current_cases_status
もOK。
OLAPがOLTPと違うもう一つの点は、その粒度の粗さである。 OLAPでは、一定間隔で全データをスナップショットしてFactテーブルに書き込むような、 荒い粒度でデータを採取することがある。 データが更新されるたびに都度更新してたら効率が悪すぎるからだ。
Dimenstion Tableは、Factを説明するための記録(形容詞)である。
Fact Tableから外部キーにより参照する。
dim_customer
やdim_category
など。
なぜ正規化された形でデータを保持するかというと、分析時にクエリしやすくするため。
中心にFact Table、その周りにFKで参照されるDimension Tableがある構成をStar Schemaと呼ぶ。
さらに、Dimension Table が推移的に別のDimension Tableを参照する構成をSnowflake Schemaと呼ぶ。 正規化が進むのでデータ容量は減らせるが、クエリ時にJOINが必要になるのでより大きい計算能力が必要になるので注意。
データ分析基盤
Data Warehouse (DWH) Architectureでは、システムからデータをぶっこ抜き、 分析に適した形に変換し、分析に適したDBに突っ込むという、比較的シンプルな方法だ。 DWHはそのDB自体を指す言葉である。 extract-transform-load (ETL) scriptを使う。
この方式の弱さの一つは、全体で1つのモデルを作ることになり、範囲が広すぎてうまくモデル化できない点だ。 一部の領域を独立したデータマートにすることで改善はするものの、 今度は全体を通したクエリができなくなる。 また、ちょっとでも元データのスキーマが変わるとETLプロセスが壊れる脆さもある。
Data Lake Architectureは、まずは一旦DBに生データをそのまま保存し、それを変換してDWHにぶっこむ方法。 元データは常に残っているので、必要に応じていろいろなモデルを作れるメリットが有る。 しかし、元データはスキーマレスで品質が一定でなく、一定の規模を超えるとカオスになりがち(いわゆるData swamp / データ沼)。
DWHやData Lakeの欠点を補うために出てきたのがData Meshである。 これはDDDのデータ分析版と言える。 Data as a Productという考え方に基づき、 Bonded Context(チーム)ごとにOLTP/OLAP modelを作る。 開発チームは、分析者の要望に合わせ様々な形式のデータを提供する責務を持つ。
読後の感想、新しく知ったこと、印象的だったこと
- アーキテクチャとは境界を設定することである。境界がないとそもそも問題解決ができない。
- システムの複雑さを決める大きな要素はDegrees of freedom (自由度)である
- CQRSはもともとEvent-sourced domain modelのために生まれた
- イベントを確実に発出するための Outbox パターン
- モデルを変換するための Anticorruption layer & Open-host service
- 分散トランザクションのための Saga pattern & Process manager
- サブドメイン単位で技術選定をする(オニオンはあくまで選択肢の一つに過ぎない)
- Event-sourced domain-model + CQRS
- State-based domain model + Ports & Adapters
- Active Record + 4-Layered
- Transaction Script + 3-Layered
- DDDはGreenfield projectよりもBrownfield projectに適している
- ストラングラーパターン
- マイクロサービスにおけるLocal complexity & Global Complexity
- Event-driven architectureにおけるイベント3種類
- (感想)トランザクションは集約単位でのみ利用し、それ以外ではsaga等を使えというのは、ほとんどのスタートアップにとってはなかなか過酷な仕組みだと思った