Domain Modeling Made Functional / 関数型ドメインモデリング
第一章 ドメインを理解する
1. Introducing Domain-Driven Design
DDD の最終ゴールは、全ての関係者が同一のメンタルモデルを使って話をすること。 これにより、早く価値を届け、高い価値を生み、無駄をなくし、保守と拡張を容易にすることができる。
やるべきことは 4 つ。
- ビジネスイベントとワークフローに着目する。データ構造ではなく。
- ドメインをサブドメインに分割する
- サブドメインのなかでモデルをつくる
- 共通言語を作って使う
ビジネスイベントを通じてドメインを理解する
ビジネスは、ある一時点のデータだけでは表現できないし価値も持たない。
ビジネスは、一連のワークフローとして表現される。 これは、データを別のデータに変換していく過程であり、そこにこそ値があるといえる。
この変換を引き起こすきっかけをドメインイベントと呼ぶ。 ドメインイベントは常に「〜が起こった」のような過去形で表現される。
例:
- イベント: 「顧客が注文ボタンを押した」
- 「商品の注文」ワークフロー:
- 注文内容の検証
- 在庫の確認と引き当て
- 決済処理
- 出荷指示の作成
- ワークフロー内で発生するデータ変換:
- 商品の状態をカート内から確定済みに変更する
- 在庫数を減少させる
- 購入履歴に注文を追加する
ドメインイベントの発見にはイベントストーミングという手法が有効である。 イベントストーミングは以下を目的とする。
- 関係者全員で共有できるモデルを作る
- 関係者全員に当事者意識を持たせる
- 仕様の齟齬を見つける
- 部署間の接続がどうなっているか見つけ出す
- 帳票の必要性を明らかにする (=> 過去の出来事を知る必要性がどれくらいあるか)
イベントを洗い出すときには、少ない範囲で満足せず、 可能な限り端まで拡張して見つけ出すことを心がける。
ワークフローを開始するための要求をコマンドと呼ぶ。 コマンドは常に命令形で表現される。 人によって引き起こされる場合と、スケジュールや外部システムからの通知で引き起こされる場合がある。 コマンドが成功すると、システムの状態が変更され、一つまたは複数のドメインイベントが記録される。
注文を確定しろ
というコマンド ->注文を確定する
というワークフロー ->注文が確定された
というイベント(+その他のイベント)発送しろ
というコマンド ->発送する
というワークフロー ->発送された
というイベント(+その他のイベント)
ドメインをサブドメインに分割する
ドメインとは「あるビジネスに密接に関連する知識の範囲」である。
ドメインはサブドメインに分けることができる。 サブドメインとは超ざっくりいうと専門家が存在する分野のことだ。 例えば請求部門の人たちがやっている仕事の範囲が「請求」というサブドメインである。
現実世界のサブドメイン群は、互いにほんの少しだけ重複するベン図として表現できる。
サブドメインのなかでモデルをつくる
ここまでに出てきたドメイン・サブドメインという用語は、現実世界・問題領域に存在する。 以降は、ソリューションの世界の話になる。
ドメインをソリューションの世界に持ち込むときには、ドメインモデルとして表現する。 これはドメインをいくぶん簡素化したモデルである。
このとき、サブドメインごとにBounded Contextsを作り、この中にモデルを作っていく。 なぜコンテキストが必要かというと、コンテキストから切り離された情報は混乱を招いたり役に立たなかったりするからである。 なぜ境界を作る必要があるかというと、相互依存をなくして保守性を高めるためである。
現実世界のサブドメインと、ソリューションの世界のBonded Contextは、1 対 1 で結びつくこともあるが、 そうでない場合もあるので注意。 1 対多にしたほうがよいこともあれば、多対 1 にしたほうがよいこともある。
Bounded Context を正しく作るのは難しく、科学ではなくアートの世界とも言えるが、いくつかのコツはある。
- ドメインエキスパートによく話を聞く
- 既存のチームや部署の構成を参考にする (ただしあくまで参考にするだけ)
- 境界をはっきりさせ、適切なサイズにする
- コンテキスト単位でチームが自律できるようにする
- ワークフローがコンテキストをまたがないようにする
Bounded Contextができたら、次にコンテキストマップをつくる。 これはコンテキスト間の関係性を示した概略図である。 連携するチームとの関係性を示すドキュメントといえる。
サブドメインは以下の3つに分類される。 まあ現実にはこんなに簡単には分けられないけど、少なくともコアと思われる部分に労力をかけることが大事である。
- Core Domain / ビジネスの価値を生む源泉となるドメイン
- Supportive Domain / 必要だけどコアじゃないドメイン
- Generic Domain / ビジネスと本質的に関係ないドメイン(アウトソースしてもかまわないもの)
ユビキタス言語を作る
あらゆる場所で使われるべき「ユビキタス言語」をつくる必要がある。 これは経営者も開発者も使う言葉になるべきだし、ソースコードの中でも現れるべき言葉である。 実装の詳細はここには一切含まれない。
ユビキタス言語はコンテキストごとに独立させるのが大事。 そうすることで、要件を簡単にできたり、深刻なデザインの失敗を防いだりすることができる。
2. ドメインを理解する
ドメインエキスパートに聞く
最初の打ち合わせでは一つのワークフロー(例えば「商品を注文する」)を高レベルな視点で把握する。
まずは入出力、つまり処理の開始点と終了点にのみ着目するところから始めるとよい。
- 入力
- そのワークフローに必要な要素
- 例えば
紙の注文書
など - 入力は複数の場合もある。例えば
紙の注文書
と商品カタログ
など。 - トリガーとは別
- 出力
- 常にイベントになる
- 例えば
注文が確定した
イベントなど - このイベントは他のBounded Contextのトリガーとなる
また、非機能要件も聞き出す。例えば以下のようなもの。
- 利用者は誰か、習熟度はどうか
- 利用頻度はどれくらいか、スパイクはあるか
- 答えを返すまでに許される時間はどれくらいか
データベースドリブンデザインの誘惑に負けない
ドメインモデルをデザインするときは、データベースのことを忘れることが大事である。 このことをPersistence ignoranceという。
データベース起点で考えると、データベースの世界に引っ張られて現実世界を歪んで見てしまうことになるからだ。 データベースは現実世界の仕事には存在しないことを忘れるな。
クラスドリブンデザインの誘惑に負けない
ClassのようなObject-orientedな思考に引っ張られるのもダメである。 どうしてもユビキタスじゃないクラスが出てきてしまって歪むから。
とにかく、要件定義中はコンピューターことや実装の技術的な詳細は一切忘れて、オープンな姿勢でいることが肝要。
ドメインをドキュメントにする
ドメインの様子がある程度わかってきたら、以下のような自然言語で表現していくとよい。 ここでも、実装の詳細は全く気にしないことが大事。
- Bounded Context:
受注
- Workflow:
注文の確定
- triggerd by:
注文書が届いた
イベント
- input:
- 注文書
- other input:
- 商品カタログ
- output events:
注文が確定した
イベント
- side effects:
- 確定した注文の情報とともに、発注者に受信通知を送る
- triggerd by:
データも同様に自然言語に近い形で書くことができる。
- Data:
Order
CustomerInfo
- AND
ShippingAddress
- AND
BillingAddress
- AND list of
OrderLines
- AND
AmountToBill
- Data:
OrderLine
Product
- AND
Quantity
- AND
Price
- Data
CustomerInfo
- unknown yet
- Data:
BillingAddress
- unknown yet
詳細な設計
続いて、更に詳細な要件をインタビューしていく。例えば以下のようなものだ。
- バリデーションは必要か(住所、メール、商品コード、数量など)
- 価格の計算方法は具体的にどうなっているか
- 外部システムへの依存があるか(住所検索システムなど)
- 処理後に行う特別な作業はあるか
開発者は優先順位を軽視しがち。迷ったら「金を生むか」で考えろ。
複雑さをドメインモデルで表現する
インタビューを進めると段々と物事が複雑になってくるがそれでいい。 以下の格言を忘れるな。
A few weeks of programming can save you hours of planning
以下などを自然言語でドキュメントにまとめていく(詳細 p36-)。
- バリデーションルール
- データの型(段階ごとにどう変わっていくか)
- ワークフロー
3. 関数型アーキテクチャ
C4アプローチによるソフトウェアアーキテクチャの定義
- System Context: システム全体
- Containers: デプロイの単位。モノリスなら1つ、マイクロなら複数
- Components: コードを組み立てるときの大まかな単位
- Classes/Modules: メソッド・関数など
Bounded Contextとソフトウェアコンポーネント
大事なのはBounded Contextがきちんと互いに独立していること。 マイクロサービスである必要はなく、モノリスで構わない。
Bounded Context間のやりとり
- Bounded Contextはイベントを介して相互に通信する
- イベントはキューイングしてもいいし、モノリスなら直接の関数呼び出しでもかまわない
- 完全に分離された設計により、各コンポーネントが自立して動作できるようにする
- コンテキスト間のデータ転送にはData Transfer Object(DTO)を使う
- ドメインオブジェクトとは別の、シリアライゼーション可能な専用オブジェクト
- 送信側でドメインオブジェクト->DTO->JSON/XMLに変換し、受信側では逆をやる
Domain Object
は境界の内側でのみ使うオブジェクト。DTOとは反対の性質。- 入力時には不正な値が入らないようにバリデーション
- 出力時には余計な情報が含まれないようバリデーション
Bounded Context間の規約
コンテキスト間の規約にはいくつかの典型的なパターンがある。
- Shared Kernel relationship
- 複数のコンテキストで共通のドメインデザインを共有する
- e.g. 注文コンテキストと発送コンテキストで
住所
モデルを共有する
- e.g. 注文コンテキストと発送コンテキストで
- 修正時には関係する全てのコンテキストの合意が必要
- 複数のコンテキストで共通のドメインデザインを共有する
- Customer / Supplier or Consumer-Driven Contract relationship
- ダウンストリーム側のコンテキスト(=Customer)が欲しいものを宣言する
- e.g. 決済コンテキストが注文コンテキストに対しカード番号と金額を要求する
- 要件を満たす限り、アップストリーム側はいつでもコード変更が可能
- ダウンストリーム側のコンテキスト(=Customer)が欲しいものを宣言する
- Comformist relationship
- ダウンストリーム側(Conformist)はアップストリーム側から与えられた情報をそのまま使う
- e.g. 注文コンテキストで「商品カタログ」の情報を使う
- ダウンストリーム側(Conformist)はアップストリーム側から与えられた情報をそのまま使う
アップストリームの情報をダウンストリーム側で使う際に、Anti-Corruption Layer(ACL) を設ける場合がある。 ACLの主目的は、外部システムの言語(データ形式、概念、語彙)を自分のドメインの言語に翻訳すること。 これによりそれぞれのドメインが独立して進化することが可能になる。 バリデーションや、外界の知識で内部が腐敗することの防止は、実は主目的ではない。
これらの規約やACLを使ってコンテキストマップを書くことができる。 マップにより、チーム間の関係性と、それらがどのように協力する(または協力しない)ことが期待されるかもわかる。 ドメインマップを組織に適用する「逆コンウェイの法則」を使うこともある。
Bounded Contextのなかのワークフロー
実装の世界では、ワークフローは単一の関数として表現される。 引数には、コマンドに埋め込まれていたデータを渡す。 返り値は、イベントオブジェクトの配列にする。これは他のワークフローの起点となる。 ワークフローにはパブリックなものとインターナルなものがある。
関数型では隠れた依存関係をなくすために、Bounded Context内でイベントを発生させることはしない。
第二章 ドメインをモデル化する
4. 型を理解する
型とは値の集合のことで、関数の入出力になりうるもの。
関数型プログラミングではValueを使う。VariablesやObjectは使わない。
- Value: immutableな値
- Variables: mutableな値
- ObjectやClass: mutableなデータ構造と、それを改変するための処理をセットにしたもの
型はANDやORによってコンポジションできる。 数値や文字列といったプリミティブな型をスタート地点として、それらをANDやORしながら複雑な型を 作っていく型システムのことを Algebraic(代数的) Type System という。
ANDで作る型をproduct types
と呼ぶ。
名前の由来は、組み合わせが掛け算で増えていくから。
ORで作る型をsum types
(またはchoice types
/tagged unions
/discriminated unions
など)と呼ぶ。
名前の由来は、組み合わせが足し算で増えていくから。
Option/Some/Noneは、値があるかもしれないしないかもしれないものを表現するために使う。
Resultは、関数が成功時も失敗時も値を返すことを表現するために使う。
(訳注:F#の細かい仕様は割愛)
5. 型を使ってドメインをモデリングする
データのモデリング (名詞)
よく使うパターン
- Simple values
- stringやintといった基礎的な値
- ただし話をするときは
OrderId
などのユビキタス言語を使う - ラッパーを作って使う(TypeScriptならBranded typeか)
- ANDを使った値の組み合わせ
- Product type
- 関連データのまとまり
- 現実世界の紙ドキュメントやその要素などに対応する
- e.g. 名前、住所、注文群、など
- ORを使った選択肢
- Sum type
- いくつかの選択肢
- 注文or見積、個数or重量、など
モデリングの初期段階では内部の詳細が不明な場合が多い。
そういうときはundefined
などを型として当てておき、後で肉付けする。
メソッドのモデリング (動詞)
前提として、関数は常にひとつの値を受取り、ひとつの値を出力する。
複数の値を入力したいときは2つのやり方がある。 一つは Product Type を使う方法。 もう一つはカリー化のパターンを使う方法(一つずつ適用していく)。 すべての値たちが密結合なのか、それとも一部がDIに適した「依存」なのかを見極め、どちらを使うか選ぶ。
複数の値を出力したい場合は、Product Typeを使う。 もしいずれかの値を出力したい場合は、Sum Typeを使う。
関数型プログラミングでは、関数がメインの値を返すことに加え、その他に追加でなにかしらの動作を行う場合がある。 この動作のことをEffectsと呼ぶ。
- 失敗する可能性がある関数はerror effectがあるという。
Result
などで表現する。 - 非同期な関数はasynchronous effectがあるという。
Async
などで表現する。
Value Object, Entities
交換できるのものはValue Objectである。 名前、住所、郵便番号など。 「彼は僕と同じ名前だ」のように表現できるものすべて。
これに対し、ユニークなアイデンティティがあり、交換不可能でライフサイクルがあるものをEntityという。 注文、請求書、顧客情報など。 識別するためのIDをもつ。IDは現実世界に存在する場合もあれば、ソリューションの世界で人工的に作らなければならない場合もある。
Value ObjectとEntityのどちらに分類するかはコンテキストにより変わってくるので注意する。
Entityでは一部の値を可変にする必要がある。 関数型プログラミングでは、元のオブジェクトをコピーして一部を書き換えることで、改変を行う(つまり、全てはイミュータブル)。
IDを型のどこに定義するか
Product Type であれば話は簡単で、単にIDのプロパティを追加すればいい。
ややこしいのは Sum Type の場合。 やり方としては、それぞれの選択肢の外側に定義する方法と、それぞれの選択肢に埋め込む方法がある。 一般的には後者のほうが、パターンマッチングなどで使いやすいため好まれる。
// それぞれの選択肢にIDを埋め込む形の例
interface Dog {
id: string
type: 'dog'
breed: string
size: 'small' | 'medium' | 'large'
}
interface Bird {
id: string
type: 'bird'
species: string
canFly: boolean
}
type AnimalWithId = Dog | Bird
Aggregate / 集約
例えば、Order(注文)がOrderLines(注文詳細群)を保持するとする。 ある特定のOrderLineの価格を編集したときは、Orderも別物になるのであわせて編集が必要になる。
このように、他のEntiry群を含むEntityをAggregateという。 また、このときにトップレベルになるEntityをAggregate Rootという。
Aggregateは一貫性が保たれるべき境界といえる。 たとえば、注文詳細の一部に変更があれば、注文合計金額も更新しないといけないよね。
Aggregateは永続性を保つ単位ともいえる。 トランザクションはこの単位で貼ることになる。
Aggregateはデータ送信するときの単位でもある。 シリアライズするときは必ず全体を対象にする。 一部だけをシリアライズすることはない。
あるAggregateが別のAggregateを参照するときはidを埋め込む。 たとえば、注文と顧客は別の一貫性を保つ必要があり、別の集約である。 よって、注文には顧客IDを埋め込む。顧客エンティティそのものを埋め込まない。
どの単位で集約するかは、ドメインエキスパートとの会話と、ドメインの理解によって導き出されるべき。
型とドメインの関係
型をうまく使うと、ドメインを型でそのまま表すことができる。 つまり現実世界のモノ・コト・手続きを、そのままコードとして表現ができる。 これにより、仕様(ドキュメント)とコードを同期させる必要がなくなり、バグも減らすことができる。
6. ドメインにおける完全性と一貫性
完全性
完全性(Integrity, Validity) とは、データがビジネスルールに適合していることを指す。 例えば、注文数が0でないとか、顧客名が空でないとか、注文日が将来日でない、などだ。 完全性を保証するための方法はいくつかある。
- スマートコンストラクタで値を検証する
- Value objectの初期化時に値を検証する(zod的な)
- intやstringなどシンプルな値に使う
- Units of measureを使って取り違いを防ぐ
- 単位を型で保証する(TSだとブランド型か)
- 不変条件を型で保証する
- 不変条件/Invariantsとは、常に真であるべき前提条件のこと
- 例えば、少なくとも1つの要素を持つ
NonEmptyArray
型を定義して使うなど
- 複雑なビジネスルールを型で保証する
- e.g.
user.idVerified
みたいなフラグを持たせるのではなく、VerifiedUser
型を定義し、検証を行う関数のみがその値を返すようにする - e.g. いくつかの状態があり得るなら、安易にオプショナルを使うのではなく、タグ付きユニオンで表現する
- メリット
- ランタイムテストが不要になる
- 処理漏れを完全になくなる
- 意図をコードに焼き付けることができる
- 「不正な状態を表現できないようにしろ」
- e.g.
一貫性
一貫性(Consistency) とは、ドメインモデルの複数の部分が事実に一致していることを指す。 例えば以下のようなものだ。
- 注文の合計金額が、注文詳細群の合計金額と一致しないのは、一貫性がない状態
- 注文が入れば請求書が発行されるはずなのに、注文だけが存在しているのは、一貫性がない状態
- 注文で割引券が使われているのに、割引券が使用済みにマークされていないのは、一貫性がない状態
単一の集約における一貫性の確保は、比較的シンプルだ。 例えば、子エンティティを更新したときに、あわせて親エンティティも更新するという関数を作るだけでいい。 集約は原子性(Atomicity)の単位でもあるので、保存時に一部のエンティティだけが不正になることもない。
Bounded Contextをまたいだ一貫性を確保するのは難しい。 即時の整合性を確保しようと思うと、Two-phase commitのようなコストの高いやり方が必要になったり、 あるいは同一トランザクション内で大量のデータ更新が必要になったりする。
よって、コンテキスト間では単にメッセージを送受するにとどめ、 時間差で必ず整合する結果整合性により一貫性を確保するとよい。
もしメッセージが消失するなどエラーが発生したときの対応方針は3つある。
- 何もしない
- ちょっとくらい整合性がなくなっても気にしない
- もしくはあとで人力でなんとかする
- 影響が軽微な場合にはこれがベスト
- リトライ
- 定期的にコンテキスト間のデータを突合して、一致しなければメッセージを再送してリトライすることで、整合性のある状態に戻す
- e.g. 注文(コンテキスト)は成立したが在庫(コンテキスト)が減っていないなら、在庫を減らすメッセージを再送
- 補償アクション(compensation action)
- 前のアクションを「もとに戻す」ことで、整合性のある状態に戻す
- e.g. クレカ決済(コンテキスト)したけど在庫(コンテキスト)がなかったとき、クレカを払い戻すなど
参考: Starbucks Does Not Use Two-Phase Commit
同じBounded Context内にある複数の集約の一貫性には、結果整合性を使うか、トランザクションを使う。 どちらが正解かは要件によるが、原則は「1つのトランザクションでは1つの集約のみを更新する」である。 複数の集約を同時に更新しないといけない状況になったら、モデルを工夫できないか考えてみると深い洞察を得られることがある。
複数の集約で共通して使われるデータの一貫性は型で保証する。 例えば0以下にはならない「銀行残高」という型を定義して使い回すなど。
7. ワークフローをパイプラインとしてモデル化する
多くのビジネスロジックは、単一のデータ変換の連なり、つまりpipelineで作られる。 関数型プログラミングの定義に基づけば、各ステップは純粋関数である必要がある。
最終的なコード例はp.138-140に掲載あり。
Inputのモデル化
ワークフローの入力は(実際の注文書などではなく)コマンドである。
コンテキストは複数の種類のコマンドを受け取る必要があるが、型は単一である必要がある。 このため、複数のコマンドをSum typeとして結合して単一の型で定義し、引数の型として使う。 コマンドの種類によってルーティングするような仕組みにする。
状態をステートマシンでモデル化
パイプを通過するたびにステートが変わっていく。
例えばEmptyCart
, ActiveCart
, PaidCart
といった具合に、ステートはSum typeとして表現される。
ステートの変更は関数を使って行う。 関数は現在のステート(e.g. EmptyCart)と必要なデータを受け取り、新しいステート(e.g. ActiveCart)を返す。
このようにステートの変遷によりデータ構造を管理する方法をステートマシンという。メリットは以下の通り。
- あるステートに特有の振る舞いを定義できる
- 全てのステートが明示的に文書化される
- 取りうるステートを網羅的に考えることを促される
各ステップの処理を型でモデル化
関数の引数、返り値、依存先、エフェクト(Result, Async)などを型で表現する。 ワークフローの最終的な出力はイベントの配列とする。 依存先については、パブリックAPIでは隠すほうが良いが、内部では明示的に含めた方が良い。
Tips: 長時間実行されるワークフロー
Sagaは失敗管理のためのパターンで、長時間トランザクションを補償可能な小さなステップに分解する。 各ステップを永続化することで独立性と再開可能性を確保し、失敗時は逆操作で整合性を回復する。
長時間のタスクがあるときに、DBロックやTwo-phase commitではうまく対処できないことから生まれた。 特に人間が関わる処理が存在する場合や、各ステップを疎結合で独立したパーツに分解したいときに最適。
第三章 モデルを実装する
8. 関数を理解する
関数型プログラミングでは、何をやるにもすべて関数でやる。
- 処理の分解: クラスやオブジェクトではなく、複数の関数に分解する
- 依存性の注入: DIではなく、関数の部分適用をする
- DRYの実現: 継承やデコレーターではなく、関数を使ってコンポジションする
関数は一級市民である。つまり、別の関数の入力、出力、依存先として使える。 関数を入力したり出力したりする関数をHigher-Order Functionsと呼ぶ。
複数の引数を取る関数を、単一の引数を取る関数の連鎖に変換することをカリー化という。 F#ではあらゆる関数は自動的にカリー化される。
カリー化された関数に一つだけ引数を与えると、その引数をあらかじめ焼き込んだ、新たな関数を得られる。 このことを 部分適用 / Partial application という。
カリー化と部分適用により、Compositionが容易になったり、不要な情報を呼び出し元に対して隠すことができたりする。
トータル関数 / Total Functionsとは、エラーなども含めた全ての起こり得る事象が、 関数のシグネチャに網羅的に記載されている関数のこと。 トータル関数を使うと、可読性、保守性、テスト容易性が高まるほか、 他の関数と簡単に組み合わせることが可能になる。
トータル関数を実現するためには以下の方法がある
- 入力値を適切に制約する
- e.g. divide by 0 を避けるために NonZeroInteger という型で受けとる
- 出力を拡張する
- e.g. 出力をユニオン型で定義し、エラーを明示的に返す
複数の関数を組み合わせることを コンポジション / Composition という。
コンポジションの重要な役割は情報の秘匿である。 利用者は入口と出口だけに集中でき、その間で何が起こっているかを気にする必要がなくなる。
関数型アプリケーションではコンポジションによりアプリケーションを作り上げる。
- アプリケーション(関数): 複数のワークフロー(関数)のコンポジション
- ワークフロー(関数): 複数のサービス(関数)のコンポジション
- サービス(関数): 複数の低レベルな(関数)のコンポジション
関数をコンポジションするときは、前の関数の出力型が次の関数の入力型とマッチする必要がある。
マッチしていないときは、最小公倍数に揃える。
例えば関数の出力がint
で、次の関数の入力がOption<int>
なら、int
をSome<int>
に変換してから渡すなど。
この変換をLiftingという。
これに限らず、型がマッチしない複数の値を共通の型に変換することで一括処理を可能にするのは、 コンポジションの問題を解決するために色々な場所で使える、便利で基本的なテクニックである。
9. 実装: パイプラインを組み上げる
(ポイントのみ抜粋)
各ステップは純粋関数にする。テストの独立性確保や、関心の分離のため。
カリー化を使うときは、関数連鎖全体をあらかじめ型として定義したのち、 その型に合うように実装をしていくと、エラー時に見やすくなっていいよ。
関数は、依存->入力->出力という順にカリー化された形で書かれる。 依存のところまでを実行して部分適用したうえで、パイプに渡していく。
type ValidateOrder =
//
(CheckProductCodeExists: Function) => // 依存
(CheckAddressExists: Function) => // 依存
(UnvalidatedOrder: object) => // 入力
ValidatedOrder // 出力
子エンティティの生成と検証は、それぞれtoOrderLine
やtoQuantity
のような関数を作って適用することで行う。
真偽値を返す関数(predicateという)は、そのままではパイプラインに組み込めない。 こういうときは Function Transformer を使って、入力値をそのまま返す関数に変換する。
// (v:T)=>boolを、(v:T)=>T に変換する
function predicateToPassThrough<T>(predicate: (value: T) => boolean) {
return (value: T): T => {
if (!predicate(value)) {
// 実際にはResult型を返すなど工夫が必要
throw new Error('Predicate condition failed')
}
return value // 条件を満たした場合のみ入力値を返す
}
}
// 使用例
const isPositive = (n: number) => n > 0
const ensurePositive = predicateToPassThrough(isPositive) // これはpipelineで扱える
関数の入出力がマッチしない場合がよくある。 依存があったり、前段の出力が後段の入力とは異なる種類の値になっている場合などだ。 こういうときは、小さなアダプターを書くか、もしくはパイプを使うのをやめて命令的なコードで書く。
依存性の注入は、main関数になるべく近いトップレベルの関数(Composition Rootという)において依存を用意し、 それをワークフロー関数に与え、以降は下位の関数に連鎖的に引き継いでいくことで実現する。 依存が多すぎる場合は、以下の対応をする。
- ワークフロー関数が多くのことをやりすぎていないか確認し、適切に分割する
- 複数の依存を一つのProduct Typeにまとめて取り回す
- 単に設定値などをpass-downしている場合などは、Composition Rootで関数をprebuildしてから取り回す
関数型プログラミングでワークフローを記述するメリットは以下の通り。
- ワークフロー関数は純粋関数であり、状態を持たず値の変更も一切しないので、テストが容易
- 全ての依存性は明示的に記述されるので、理解が容易
- 副作用は引数の中にカプセル化されており、そのコントロールとテストが容易
ワークフローに関する処理は一つのファイル、例えばplaceOrderWorkflow.ts
に全てまとめ、以下の順で書くと良い。
- 型定義
- ステップ群
- ステップ群をまとめあげたワークフロー関数
10. 実装: エラーに対処する
エラーの種類と対処
エラーには3種類ある。
Domain Errorsは、商品番号が違うとか、合計額が違うといった、ドメインに関するエラー。 ドメインの世界で対処すべきエラーなので、ドメインエキスパートと話をして、 ドメインモデルに組み込んで対処する。
Panicsは、メモリ不足といった、リカバリ不能なシステムエラー。 0除算やnull参照のような、開発者の凡ミスによるリカバリ不能なもの。 エラーを投げてワークフローを離脱し、トップレベル付近で捕捉してログするなどして対処する。
Infrastructure Errorsは、ネットワークタイムアウトや外部APIへの疎通エラーなど、 ドメインとは関係ないものの対処が必要なエラー。 対処方法はケースにより異なる。ドメインモデルに組み込むか、離脱してログするなどして対処する。
エラーはSum Typeとして表現されることが多い。 ドキュメントの役割も持ち、拡張が容易で、漏れもなくせるからだ。
エラーはモデリング段階ではなく運用段階で見つかることも多い。 そのエラーがドメインエラーであれば、モデルを更新する必要がある。
Raiload-oriented programming
Error effect(成功または失敗がありえる)といった文脈を取り出して処理し、 結果を再び文脈に包んで返す関数のことを、monadic functions / switch functions などと呼ぶ。
エラー対処のコードを愚直に書くとコードが醜くなるため、 関数側プログラミングでは Raiload-oriented programming が採用される。
これは成功パスと失敗パス(two-track ≒ Result型)を鉄道の線路に例えたもので、 エラーが発生すると自動的に失敗パスに切り替わって残りの処理をスキップする。
bindとmapを使って関数同士を接続する
Raiload-oriented programmingで扱う関数は、Result(以下でいうtwoTrackInput
)を受取り、かつ返すmonadic functionsである必要がある。
Resultを出力する関数(Error effectがある関数)をmonadicにするためには、bind
と呼ばれる関数アダプタを使う。
let bind switchFn twoTrackInput =
match twoTrackInput with
| Ok a -> switchFn a // switchFnはResult型を返す前提
| Error e -> Error e
Resultを入力も出力もしない(Error effectがない)関数をmonadicにするには、map
と呼ばれる関数アダプタを使う。
let map f twoTrackInput =
match twoTrackInput with
| Ok a -> Ok (f a) // Result型でラップして返す
| Error e -> Error e
ワークフロー内でmonadicな関数をbind
で接続するには、前後の入出力の型が完全に一致しないといけない。
成功系については、処理の内部で型を変換するなどして、マッチさせればいい。
問題は失敗系で、ワークフロー内の全ての関数で同じエラー型を使う必要がある。
このためには、ワークフロー内で使う共通のエラー型を作成したうえで、map
の失敗系版であるmapError
を使う。
let mapError f twoTrackInput =
match twoTrackInput with
| Ok a -> Ok a
| Error e -> Error (f e) // fには、エラーを受け取り、別のワークフロー内で共通して使えるエラーを返す関数を渡す
その他の関数の取り扱い
- 例外を投げる関数はどうする?
- ->
tryCatch
的なコードによりResult型を返す関数に変換する。
- ->
- 出力がないDead-endな関数はどうする?
- -> 値を出力するように
tee
のような関数でラップしたうえでmap
する。
- -> 値を出力するように
let tee f x =
f x
x
楽に書く
関数を組み合わせていくのは煩雑な作業ではある。
let!
やresult
(小文字)といったComputation Expressionを使うと、
Resultがネストしていったとしても楽に書けるようになる(Rustの?
演算子を使うイメージ)。
これらは自動でResult型でラップしたりアンラップしてくれるというもの。
配列に対して処理をするときは、結果がlist of Result
になりがち。
それらをResult of list
に変換するutil関数を書いておくと便利。
monadとは
(何言っとるかわからん、省略)
11. Serialization
ワークフローの入力はコマンド、出力はイベントである。 これらは、たとえばメッセージキューとかWebリクエストなどといった、 ドメイン外部のインフラとの間で受け取ったり受け渡したりする。 外部はドメインのことを知らないので、シリアライズ・デシリアライズが必要となる。
- persistence: Stateがそれを生成したプロセスよりも長く存続すること
- serialization: ドメインの世界の表現を、JSONなどの永続化しやすい形に変換すること
ドメインオブジェクトは直接(デ)シリアライズするには向いていないので、 中間にプリミティブ型だけで定義されたDTOを挟む。
DTOはドメイン知識ではないので、ドメインレイヤーの外部に書く。 DTOはBounded Context外部との契約として機能するので、変更は慎重に行う。 間違ってもライブラリ任せの自動処理にしないこと。 場合によっては複数のDTOをバージョン管理して互換性を維持する必要性がでてくることも。
処理の流れは以下のようになる。 シリアライズは常に成功するのでResult型である必要はない。 逆に、デシリアライズはResult型になるが、要件によっては単にErrorを投げるでもよい。
- deserialize (Error effetあり)
- DTO.toDomain (Error effectあり)
- workflow
- DTO.fromDomain (Error effectなし)
- serialize (Error effectなし)
12. Persistence
永続化に関することを端に追いやる
ドメイン部分だけを子関数に切り出すなどし、IO部分だけを端に追いやり、ドメイン部分を間に挟む。 名付けてI/Oサンドイッチである。
切り出したドメイン部分(子関数部分)は純粋であるため、テストは容易である。
IO部分は一般的に重要度が相対的に低く、E2Eテストで担保すれば十分である(ほんとかよ)。 もしIO部分も含めてユニットテストしたい場合は、 ワークフロー関数がDB関連の依存を受け取れる(注入できる)ようにカリー化しておく。
I/Oを扱うようなComposite functionは、なるべくアプリのトップレベル(円の外側)に配置しないといけない。 例えば Composition Root とか、コントローラーの中などだ。
(Repositoryパターンは不要と書いてあるが、関数でほぼ同じことをやっているように見えたので、つまり必要ってことだと思う)
(ワークフロー内でIOがある場合はI/Oダブルチーズバーガー的にしろという記述もあったが、あまり現実的と思えないので省略)
Command Query Separation
CQSの原則とは、データを取得するか、副作用を起こすか、どちらか一方にしろという原則である。
CQRS (Command Query Responsibility Segregation)とは、 クエリ用に最適化されたモデルをコマンドで使うのは避けましょうという考え方に基づいた、分離の手法。
理由は、そのままでは保存時に使えなかったりするからだ。 例えばクエリ用だとデータが非正規化されてて扱いにくかったりするよね。
また、クエリとコマンドを同様に扱うと不自然になるからだ。 たとえば、4つのクエリオブジェクトのうち1つだけが更新用とか、変でしょう。
なので、型定義もWriteModel.Customer
とReadModel.Customer
というように分けて定義していくと良い。
Bunded Contextは専用のストレージを持つべき
Contextが独立して進化していけるようにするため、Contextごとにストレージは独立させておくべきである。 そのContextのみがストレージにアクセスし、外部から操作したい場合は必ずAPIを介して行う。 独立というのは、別のDBでもいいし、DBは共有しつつ何らかのnamespaceで分けてもいい。
帳表やBIでは複数のコンテキストにアクセスしていい?
コンテキスト同士が密結合になるのでやめたほうがよい。 帳表やBIのためのBounded Contextを作って、ほかと同じように作るべし。 具体的には、イベントを受取り、データを蓄積(コピー)していく感じ。
もしくは、ETLを導入したうえで、都度アドホックなクエリを実行して対処する。 BI目的ならこれで十分な場合もある。
RDBへの保存と読み出し
Sum typesの保存方法
- 1つのテーブルに全て保存する方法
- タグごとにフラグ列を作ったうえで、紐づく情報を列として横に並べていく
- 紐づくデータが小さい場合に最適
- タグごとに子テーブルを作る方法
- 紐づくデータが大きい場合に最適
ネストしているプロパティの保存方法
- ネストしているのがアイデンティティを持つ子エンティティなら、専用の子テーブルを作る
- ネストしているのがアイデンティティを持たない Value object なら、親テーブルに列群として追加する。
Sum typesを読み出したときは、タグの種類に対応する型に変換しつつ、ドメインオブジェクトを組み立てる。
サービスをまたがるトランザクションはオーバーヘッドが大きく、ヘビーかつ遅すぎて使い物にならない。 結果整合性で妥協し、失敗時には調停(補償)を行うのがよいだろう。
13. 簡潔さを保ちつつデザインを進化させる
以下、拡張に強いコードにするためのTips
Active Patternを使ってロジックを簡素化する
多段if文を使って何らかの計算をするようなコードは早期に破綻する。 こういうときはActive Patternを使って型の力を活用することで、 ヌケモレがなくなり、意図も明確に伝わるようになる。 これは処理を二段階に分け、前段ではカテゴリ化を、後段では実際の処理を行う方法だ。
// ===========================================
// 🚫 普通の書き方(Active Patternじゃない版)
// ===========================================
function describeNumber(x: number): string {
if (x % 2 === 0) {
return '偶数'
} else {
if (x > 0) {
return '奇数(正)'
} else if (x < 0) {
return '奇数(負)'
} else {
return '奇数(ゼロ)'
}
}
}
// ===========================================
// ✅ Active Pattern版
// ===========================================
type NumberPattern = 'even' | 'odd'
type SignPattern = 'positive' | 'zero' | 'negative'
function getNumberPattern(x: number): NumberPattern {
return x % 2 === 0 ? 'even' : 'odd'
}
function getSignPattern(x: number): SignPattern {
if (x > 0) return 'positive'
if (x === 0) return 'zero'
return 'negative'
}
function describeNumber(x: number): string {
const pattern = getNumberPattern(x)
const signPattern = getSignPattern(x)
switch (pattern) {
case 'even':
return '偶数'
case 'odd':
switch (signPattern) {
case 'positive':
return '奇数(正)'
case 'negative':
return '奇数(負)'
case 'zero':
return '奇数(ゼロ)'
}
}
}
ステップを追加するときは新たに出力の形を作る
安定して稼働している既存のステップや出力の型を安易に拡張するのではなく、 新しいステップと新しい型を作ることを検討すること。 そうすることで、ステップ間の独立性が高まり、事故が減り、削除も簡単になることが多い。
ビジネスルールの出力ではなく入力をモデル化する
例えばVIPユーザーであれば送料を無料にするというビジネスルールを追加したい場合を考えてみる。
このとき、ルールの出力(送料無料)だけをモデル化してはダメ。
ルールへの入力(isVipUser
)をモデル化し、それにより答え(送料)を導出するようにする。
入力に対する出力、つまりビジネスルールは容易に変更があり得るので、こうしておくと将来の変更に対応しやすいからだ。
プロパティにはisVip
というフラグを持つのではなく、
type VipStatus = Normal | Vip
のようなSum Typeで持つことで拡張性が高まることも。
依存はファクトリとして作成してドメインの外に出す
例えばクーポンコードによって価格表を変えるという要件を追加したい場合を考えてみる。 クーポンコードに応じた価格表の取得やキャッシュを行うことは注文ワークフローの関心外なので、 Factoryにしてワークフローに依存として注入するのがよい。
// 価格表を外部サービスから取得し、キャッシュしたうえで、lookupするための関数を返すファクトリ
type GetPriceFactory = (coupon: string?) => GetPrice
制約を追加するためにラッパーを使う
例えばビジネス時間にのみ注文を受け付ける、という要件を追加する場合、注文ワークフローをラップする関数を定義する。 こうすればワークフローに一切手を加えることなく、要件の追加ができる。
let businessHoursOnly getHour onError onSuccess =
let hour = getHour()
if hour >= 9 && hour < 17 then
onSuccess()
else
onError()
特定の処理が複雑になってきたらコンテキストを独立させる
例えば値付けのロジックが複雑になってきた場合、値付けのコンテキストを独立させるべきである。 値付けのロジックは値付けコンテキストに閉じ込め、注文ワークフローは値付けコンテキストに依存するようにする。
複数のコンテキストを扱う機能はコンテキストを独立させる
例えば注文状況(注文済み・支払済・発送済み)などを扱おうとすると複数のコンテキストにまたがる。
こういうときは新たに注文状況
というコンテキストを作る。
そして、関連する複数コンテキストからのイベントを受け取って、然るべき処理を行う。
まとめ
重要なこと
- ドメインの深い共通理解を構築する
- 低レベル設計を始める前に、ドメインの深い共通理解を構築することを目指すべき
- イベントストーミングのような発見手法や、ユビキタス言語のようなコミュニケーション技法が使える
- ソリューション空間の分割
- ソリューション空間を自律的で疎結合な境界付きコンテキストに分割し、それぞれが独立して進化できるようにする
- 各ワークフローを明示的な入力と出力を持つ独立したパイプラインとして表現する
- 型による要件の捕捉
- コードを実装する前に、型ベース記法を使用してドメインの名詞と動詞の両方を捕捉して要件を把握する
- 名詞はほぼ常に代数的型システムで表現でき、動詞は関数で表現できる
- 制約とビジネスルールを型で表現する
- 可能な限り重要な制約とビジネスルールを型システムで捕捉するよう努める
- モットーは「不正な状態を表現不可能にする」
- 「純粋」で「トータル」な関数の設計
- 関数を「純粋」で「トータル」になるよう設計する
- 純粋: すべての動作が完全に予測可能で、隠れた依存関係がないこと
- トータル: 入出力が全て型で表現されていること(例外や非同期処理も含む)
- 関数を「純粋」で「トータル」になるよう設計する
本性で紹介された関数型プログラミング技法
- 小さな関数の合成のみを使った完全なワークフローの構築
- 小さな関数の組み合わせだけを使用して、完全なワークフローを構築する
- 依存関係がある場合の関数のパラメータ化
- 依存関係がある場合、または単に決定を先延ばしにしたい場合には、関数をパラメータ化(≒カリー化)する
- 部分適用による依存関係の組み込み
- 部分適用を使用して依存関係を関数に組み込み、関数をより簡単に合成できるようにし、不要な実装詳細を隠す
- 関数を様々な形に変換するHOCの作成
- 他の関数を様々な形に変換できる特別な関数を作成する
- 特に、エラーを返す関数を簡単に合成できる2トラック関数に変換するために使用した「アダプターブロック」であるbindなど
- 型の不一致問題の解決
- 異なる型を共通の型に「リフト」することで型の不一致問題を解決する