😽
『関数型ドメインモデリング(Domain Modeling Made Functional)』を読んだ 01
関数型言語から見たドメインモデリングに興味があり、本書を手に取りました。
ドメインモデリングの本ですが、最初に「なぜドメイン駆動設計をするといいのか」や「実際に取り組み始める際の流れや手法」の解説があります。DDDの紹介の章で「開発者の仕事はコードを書くことではなく、ソフトウェアによって問題を解決すること」と著者が言うように、いきなりコードから解説を始めるのではなく、まずどうやってワークショップを進めていけばいいのかや実際のインタビューの方法まで、ビジネス側を含めて解像度高く説明しているところがわかりやすかったです。
また、概念的な「境界づけられたコンテキスト」などのDDDで使う専門用語も詳しく説明しています。すぐに関数型のモデリング手法に入らずに、まずはきっちり概念の説明をしてから入っていきます。ここにも先ほどの著者の信念が表れているように思いました。
DDDをシームレスに関数型プログラミングに繋げていくのも理解が深まりました。特に、ワークショップやインタビューから得たDDDのコアドメインを、オニオンアーキテクチャを通して関数型プログラミングのコアな関心として表現して繋いでいく箇所は、DDD -> アーキテクチャ -> 関数型プログラミングでの実装、という実際の開発の流れの解像度を大きく高めてくれました。いきなりテーブル設計に入らないことや、ドメインエキスパートと対話しながら同じ言語を使って設計していくことを口酸っぱく言っているのはとても沁みました。また以前から個人的に感じていたDDDと関数型プログラミングの親和性をあらためて認識できました。
訳者まえがき
- 関数型ドメインモデリングは、ソフトウェアの複雑性に対処するときに役立つ
- イミュータブルなデータとイミュータブルな関数を組み合わせた「型」により、ドメインの状態遷移を明示的に表現する方法が提案されている
- ドメイン知識を静的な型に落とし込むことで、ソフトウェアの複雑性を管理する
- 本書の対象
- ドメインモデリングの実践に関心があり、関数型プログラミングにも興味を持つ読者層
- イミュータブルなデータとイミュータブルな関数を組み合わせた型で、ドメイン状態遷移を明示的に表現する方法を提案している
- ドメイン知識を静的な型に落とし込むことで、ソフトウェアの複雑性を適切に管理する
- なぜ翻訳したか
- モデリングという実践的な課題に即して関数型プログラミングを解説することで、この手法の普及に役立つと考えたから
- ユビキタス言語の語彙が、モデルからコードに自然につながっていく様子を読み取ってほしい
はじめに
- 本書の目的
- ドメインモデリングの手段として、関数型プログラミングが優れていて明確でシンプルな設計を生み出せると示すこと
- 想定読者
- 型と関数だけでどうやってドメインをモデル化して実装するのか興味がある
- ドメイン駆動設計を把握し、OOPやデータベースファースト設計とどう違うのか学びたい
- 関数型プログラミングがどうDDDに役立つのか学びたい
- 関数型プログラミングを学びたいが、理論や抽象化が多すぎてウッとなっている
- F#と関数型プログラミングが現実的なドメインにどう適用できるのか見てみたい
- 3つのパート
- 1: ドメインの理解
- ドメインエキスパートやその他の非技術的なチームメンバーとのコミュニケーションの重要性と、実世界の概念に基づいたドメインモデルが共有されることの価値
- 2: ドメインのモデリング
- ドメインの読みやすいドキュメントとしてだけでなく、実装を構築できるコンパイル可能なフレームワークとしても機能するコードを書く
- 3: モデルの実装
- 合成、部分適用、モナドなど関数型プログラミングのテクニックの使う方を学ぶ
- ドメインをモデル化して実装するために必要なことだけを取り上げる
- 太陽が昇るのと同じくらい確実に、要件は変化する
- 最終章では、ドメインが進化するときにありがちな方向性と、それに対応するための設計を見ていく
- 1: ドメインの理解
第一部: ドメインの理解
- 第1部の内容
- ドメイン駆動設計の背景にある考え方と、ドメイン理解を共有することの重要性について
- イベントストーミングなどで共通の理解を構築する方法
- 大規模なドメインを小さなコンポーネントに分解して、それぞれ独立して実装・進化できるようにする方法
1. ドメイン駆動設計の紹介
ドメイン駆動設計について
- ドメイン駆動設計について
- 開発者の仕事はコードを書くことではなく、ソフトウェアによって問題を解決すること
- コーディングはソフトウェア開発の一側面にすぎない
- ソフトウェア開発を、入力(要件)と出力(最終成果物)を持つパイプラインと考えると、ゴミを入れればゴミが出てくるのルールが当てはまる
- 要件が不明確だったり設計が悪かったりすれば、いくらコーディングしてもよい出力は得られない
- 「ゴミを入れる」部分を最小限にすることが必要
- それがドメイン駆動設計(DDD)
- 開発者の仕事はコードを書くことではなく、ソフトウェアによって問題を解決すること
モデルを共有することの重要性
- モデルを共有することの重要性
- 本番環境にリリースされるのは、ドメインエキスパートの理解ではなく、開発者の理解
- 開発者が完全に理解したと胸を張って言えるようにするには?
- 仕様書や要件定義書で詳細をすべて把握する方法では、問題を理解している人(目的不確実性の検証)と、その解決手段を作り出す人(手段不確実性の検証)の間に距離ができてしまう
- ドメインエキスパートと開発チーム
- ドメインエキスパートの定義: 会えば一発でわかる
- 伝言ゲームはプロジェクトの成功に致命的な影響を与える
- ドメインエキスパートと開発チーム
- ドメインエキスパートと開発チームの間にフィードバックループを導入する
- このような反復的プロセスは、アジャイル開発の中核となる
- だが、まだドメインエキスパートのメンタルモデルを開発チームが翻訳する過程でゆがみが生じたり、重要な機微が失われたりする
- ドメインエキスパート、開発チーム、その他のステークホルダー、そしてソースコード自体が、すべて同じメンタルモデルを共有する環境を作るという、3つ目のアプローチ
- 市場投入までの時間が短縮される
- ビジネス価値が向上する
- 無駄が削減される
- メンテナンスと進化の容易性が上がる
- 共通のメンタルモデルを作成するには?
-
DDDコミュニティのガイドライン
- データ構造ではなく、ビジネスイベントやワークフローに焦点を当てる
- ドメインをより小さなサブドメインに分割する
- 各サブドメインのモデルを解決空間に作成する
- プロジェクトに関わるすべての人が共有し、コードのあらゆる場所で使用される共通言語(ユビキタス言語)を開発する
-
DDDコミュニティのガイドライン
- 例: 最強無比の納品マシーン
- ビヘイビア駆動開発の推進者Dan North氏のメンタルモデル共有体験として、貿易会社において開発者が本物のトレーダーと一緒にトレーダーとして訓練を受け、自身がドメインエキスパートとなった
- 仕様書や要件定義書で詳細をすべて把握する方法では、問題を理解している人(目的不確実性の検証)と、その解決手段を作り出す人(手段不確実性の検証)の間に距離ができてしまう
ビジネスイベントによるドメインの理解
- ビジネスイベントによるドメインの理解の流れ
-
まずはデータ構造ではなく、ビジネスイベントやワークフローに焦点を当てる
- ビジネスはデータを持っているだけでなく、データを変換するものだから
- 典型的なビジネスプロセスは、データやドキュメントの変換の連続だと考えられる
- 変換の過程で、ビジネスの価値が生み出される
- 変換がどのように機能し、互いにどのように関係しているかを理解することが重要
- それを設計の一部として表現する
- これをドメインイベントと呼ぶ
- 常に過去形で、「〇〇した」「〇〇された」と書かれる
- きっかけとなったイベントは変更できない事実だから
- 例: 新しい注文書が届いた
- ドメインイベントは、モデル化したいほとんどすべてのビジネスプロセスの出発点となる
- ドメインを理解することに集中し、徹底的に理解してから、同じものをデジタルでどのように実装するか考える
- 一度にすべてをデジタルに移行せず、もっとも効果が期待できる部分から着手すべき
- また、そのドメインについて概念レベルで把握した結果は、紙やデジタルの区別はなく、特定の実装に依存しない
- 例: 会計のドメインの概念や用語は何百年も変わっていない
-
イベントストーミングによるドメインの探索
- ドメインに関与するさまざまなステークホルダーを集めてワークショップを行う
- 「質問のある人と、答えられる人」を集める
- 知っていることは壁に貼り、知らないことは質問するという形で参加する
- 書籍: Alberto Brandolini 『EventStorming』
- イベントストーミングワークショップのメリット
- ビジネスの共通モデルの作成で解像度を高め共感を醸成できる
- イベントを明らかにするだけでなく、参加者の解像度を高めるという効果がある
- 全員が大きな壁で同じものを見るから
- コミュニケーションと共通モデルに重点を置き、ウチと他所の対立思考を避けられる
- 他のチームに対する自分の思い込みが間違っていることに気づくこともある
- 共感が生まれる
- イベントを明らかにするだけでなく、参加者の解像度を高めるという効果がある
- 全チームの業務の把握ができる
- 見落とされている人も発言できる
- 例: 受注システムにおいて請求書発行業務の観点を忘れていたなど
- 要件のギャップの発見がある
- 例: 「オリー、注文が終わったらお客さんに伝えるんじゃなかった?」の発言で、注文確認を顧客に送ったというイベントを見つけられる
- 質問に明確な答えがない場合は、質問を壁に掲示してさらに議論のきっかけにできる
- 議論や意見の相違は解像度を高めるチャンスとなる
- 初期設計の解像度を高められる
- チーム間の連携が生まれる
- イベントをタイムライン上でグループ化すると、あるチームのアウトプットが別のチームのインプットだと明らかになることがある
- レポーティング要件がわかる
- 読み取られるだけのモデルがドメインには含まれることがわかる
- ビジネスの共通モデルの作成で解像度を高め共感を醸成できる
- 表現する言葉の定義
- シナリオ: 顧客(またはユーザー)が達成したい目標を記述する
- アジャイル開発におけるストーリーに似ている
- ユーザーからの視点に焦点を合わせる
- ユースケース: シナリオをより詳細にしたもので、ある目標を達成するためにユーザーが行うべきインタラクションやその他のステップを一般的な言葉で表現したもの
- アジャイル開発におけるストーリーに似ている
- ビジネスプロセス: ビジネス中心に焦点を当てて、ビジネスが達成したい目標を記述する
- ワークフロー: ビジネスプロセスの一部を詳細に記述したもので、あるビジネスゴールやサブゴールを達成するために従業員が行うべき正確なステップを記載する
- シナリオ: 顧客(またはユーザー)が達成したい目標を記述する
- ドメインに関与するさまざまなステークホルダーを集めてワークショップを行う
-
ドメインを探索する: 受注システムの例
- 「装置や機器を製造している小さな会社です。急成長していて現在のプロセスでは追いつかない。紙ベースの業務を電子化してDXしたい。セルフサービスのWebサイトを作って注文確定や注文状況の確認をお客様自身にやってもらいたい。」
-
- まずはビジネスイベントにフォーカスする
- 受注部門のオリー
- 「注文や見積依頼が来たときに対応しています」
- 「お客様から郵送で依頼書が送られてきます」(きっかけ)
- 注文書・見積依頼書を受け取った
- 発送部門のサム
- 「注文の最終確認が終わると、梱包して発送します」
- 「受注部門からの指示があったらやります」(きっかけ)
- 注文が確定した
-
- イベントを端まで広げる
- 受注部門のオリー
- 「注文書を受け取ったイベントのトリガーは、毎朝郵便物を開けてみると紙で依頼書が送られてきていて、注文か見積依頼かを分類しています」(きっかけ)
- 郵便物を受け取った
- 「注文書を受け取ったイベントのトリガーは、毎朝郵便物を開けてみると紙で依頼書が送られてきていて、注文か見積依頼かを分類しています」(きっかけ)
- 発送部門のサム
- 「注文を発送した後は、もし注文が発送状況確認つきであれば、宅配業者から通知を受け取れます」(きっかけ)
- 発送が顧客に届いた
- 「注文を発送した後は、もし注文が発送状況確認つきであれば、宅配業者から通知を受け取れます」(きっかけ)
-
- コマンドを文書化する
- 「これらのドメインイベントを引き起こしたのは何ですか?」という質問でわかる
- DDDではこれをコマンドと呼ぶ
- 常に現在型で、「〇〇する」と書かれる
- すべてのコマンドが成功するわけではない
- 注文書が郵便物に紛れていて見つけられなかったりすることもある
- コマンドが成功したなら、ワークフローが開始され、それに対応するドメインイベントが作成される
- コマンドが「〇〇する」だった場合、ワークフローが〇〇をすれば、対応するドメインイベントは「〇〇した」「〇〇された」となる
- 例: コマンド「注文を確定する」に対するドメインイベントは「注文が確定された」となる
- イベントがコマンドのトリガーとなり、コマンドがビジネスワークフローを開始する
- ビジネスプロセスを「入力と出力を持つパイプライン」として考える
- これが関数型プログラミングの仕組みと見事にマッチする
- ビジネスプロセスを「入力と出力を持つパイプライン」として考える
- ただ、すべてのイベントがコマンドと関連づけられる必要はない
- 月末締めや在庫切れなどスケジューラーや監視システムによってトリガーされるものもある
-
まずはデータ構造ではなく、ビジネスイベントやワークフローに焦点を当てる
ドメインをサブドメインに分割する
- ドメインをサブドメインに分割する
- 大きな問題に直面した時、それを小さなコンポーネントに分割して別々に対処する
- 今回のビジネスでは別々の部門が存在しているので、設計でも同じように分離できる
- 個々の分野をドメインと呼ぶ
- ドメイン駆動設計の世界では、ドメインを「首尾一貫した知識の領域」と定義できる
- さらに言い換えると、ドメインとは、「ドメインエキスパート」が専門としているものごと、ただそれだけ
- 例: 「請求書作成」は、ドメインエキスパートである請求書作成部門の人々が行うものごと
境界づけられたコンテキストを利用した解決手段の作成
- 境界づけられたコンテキストを利用した解決手段の作成
- 境界づけられたコンテキストとは
- 例: 「ねえ、オリー、請求プロセスについて知ってる?」オリー「少しなら。でも詳細は請求チームに聞いて」。この発言が別のサブドメインだというシグナルとなる。
- 特定の問題を解決するために関連する情報だけを取り入れるべき
- 「問題空間」と「解決空間」の区別を作る
- 「境界づけられたコンテキスト」という言葉を使うのは、コンテキストを意識し、境界を意識することに集中するため
- なぜコンテキストなのか?
- それぞれのコンテキストは、解決手段における何らかの専門的な知識を表しているから
- コンテキストの中では、共通の言語を共有し、設計は首尾一貫して統一されている
- なぜ境界づけられているのか?
- 現実では境界は曖昧だが、ソフトウェアの世界では別々のサブシステム間を疎結合にして、それらが独立して進化できるようにしたいから
- そのために、サブシステム間に明示的なAPIを設けたり、共有コードなどの依存関係を避けたりする
- 複雑さを減らしてメンテナンスを容易にできる
- 問題空間のドメインは常に解決空間のコンテキストと1対1の関係にあるとは限らない
- コンテキストを正しく区別する
- ドメイン駆動設計のもっとも重要な課題の1つは、コンテキストの境界を正しく設定すること
- ガイドライン
- ドメインエキスパートの声に耳を傾ける
- 彼らが同じ言語を共有し、同じ問題に焦点を当てている場合、おそらく同じサブドメイン(1つの境界づけられたコンテキストに対応する)で作業しているから
- 既存のチームや部門の境界に注目する
- 境界づけられたコンテキストの「境界づけられた」という部分を忘れない
- スコープの逸脱に注意する
- 境界部分を守るためには冷酷になる必要がある
- 大きすぎたり、曖昧すぎたりする境界は、境界ではない
- 例: よい塀がよい隣人を作る
- 自律性を目指して設計する
- 1つの境界づけられたコンテキストに2組の派閥の違うドメインエキスパートがる場合には、分けたほうがいい
- 1つの巨大なコンテキストですべての人を幸せにしようとするよりも、境界づけられたコンテキストを分離して自律性を持たせて、個別に進化できるようにしたほうがいい
- 摩擦のないビジネスワークフローを目指して設計する
- ワークフローがスムーズになるようにコンテキストのリファクタリングを検討する
- 設計のさまざまな純粋さよりも、ビジネスおよび顧客の価値に常に焦点を当てる
- ドメインエキスパートの声に耳を傾ける
- コンテキストマップの作成
- コンテキストを定義した後は、コンテキスト間の相互作用を伝えるためにコンテキストマップを作成する
- すぐに設計の詳細に入らない
- 例: 旅行で使う路線図のようなもの。各都市間の利用可能なルートは書いてあるが、これはあくまでフライトプランを立てるための地図
- コンテキストを定義した後は、コンテキスト間の相互作用を伝えるためにコンテキストマップを作成する
- もっとも重要な境界づけられたコンテキストに焦点を当てる
- ドメインの種類
- ビジネス上の優位性を提供しお金を稼ぐコアドメインが重要
- 必要不可欠だがコアではないドメインは支援ドメインと呼ばれる
- 企業の固有のものではないものは汎用ドメインと呼ばれる
- 優先順位をつけて、すべての境界づけられたコンテキストを同時に実装しようとしないことが重要
- ドメインの種類
- 境界づけられたコンテキストとは
ユビキタス言語の創造
- ユビキタス言語の創造
- ユビキタス言語とは
- コードとドメインエキスパートは同じモデルを共有しなければならない
- ビジネスドメインの共有メンタルモデルを定義する
- この言語はプロジェクトのあらゆる場所で使用されるべき
- 要件、設計、ソースコード
- ドメインエキスパートが何かを「注文」と呼んでいるなら、それに対応した
Order
と呼ばれるコードを用意し、同じように振る舞うべき - このユビキタス言語としてのモデルを設計として公開する
- コードとドメインエキスパートは同じモデルを共有しなければならない
- ユビキタス言語の特徴
- ユビキタス言語はドメインエキスパートが一方的に指示するものではない
- チーム全員が協力して行う
- 静的ではなく絶えず更新されていく
- すべてのドメインやコンテキストをカバーする単一のユビキタス言語は構築できない
- それぞれのコンテキストには方言があるから
- 例: 「クラス」はOOPでの意味とCSSでの意味がまったく違う
- 「顧客」や「製品」という言葉を異なる文脈で同じ意味にしようとすると、要求は複雑になり、下手すると重大な設計ミスとなる
- 例: イベントストーミングにおいて、発送部門が考える「注文」と、請求部門が考える「注文」の定義は微妙に異なっている。発送部門は在庫量や数量を気にかけているが、請求部門は価格や金額に関心がある
- ユビキタス言語はドメインエキスパートが一方的に指示するものではない
- ユビキタス言語とは
ドメイン駆動設計の概念の要約
- ドメイン駆動設計の概念の要約
- ドメイン: 解決しようとしている問題に関連する知識の領域であり、ドメインエキスパートが専門としているものごと
- ドメインモデル: あるドメインにおいて、特定の問題に関連した側面を単純化して表現したものの集合。問題空間の一部
- ユビキタス言語: ドメインに関連する概念と語彙の集合であり、チームメンバーとソースコードの両方で共有される
- 境界づけられたコンテキスト: 解決空間内のサブシステムであり、しばしば問題空間のサブドメインに対応する。ユビキタス言語の独自の方言を持っている
- コンテキストマップ: 境界づけられたコンテキストの集合体と関係性を示す図
- 例: 利用可能な経路の路線図
- ドメインイベント: システムで起こったことの記録であり、常に過去形で記述される。多くの場合、新たな活動を引き起こすトリガー(きっかけ)となる
- 例: 〇〇した
- コマンド: 人や他のイベントが行う、ドメインイベントを引き起こすトリガー(きっかけ)。成功するとシステムの状態が変化し、1つ以上のドメインイベントが記録される
- 例: 〇〇する
まとめ
- まとめ
- ドメインと解決手段で共有されたモデルを作成することの重要性を強調した
- 4つのガイドライン
- データよりもイベントやプロセス
- イベントストーミングで解像度を高めながら認識を共有する
- (ワークショップを行うときは、「解像度が上がったか・認識が共有できたか」をアンケートで計測するなど)
- ドメインをより小さなサブドメインに
- 「あの人が詳しい」という発言が別のサブドメインだというシグナルとなる。
- サブドメインのモデルを解決空間に作成する
- サブドメインに対応する境界づけられたコンテキストを作成する
- 相互作用を示すコンテキストマップを作成する
- 自動化によって、もっとも付加価値を高められるコアドメインに注力する
- ユビキタス言語ですべての人と共有する
- 「注文書」「見積依頼書」「注文」などの用語に対して、共通の理解を保つために随時更新される文書を作成する
- (Github Pagesでmarkdown形式で公式情報として作成するとよさそう)
- 「注文書」「見積依頼書」「注文」などの用語に対して、共通の理解を保つために随時更新される文書を作成する
- データよりもイベントやプロセス
- 次にやること
- さらに解像度を高める
- 注文処理のワークフローでの具体的な作業内容や、インプットやアウトプット、相互に作用する他のコンテキストや、他のチームでの「注文」の概念との違いの整理など
- さらに解像度を高める
2. ドメインの理解
ドメインエキスパートへのインタビュー
- ドメインエキスパートへのインタビュー
- (業務マニュアルや手順書をインタビューによってドキュメント化していくようなイメージ)
- インタビューの方法
- 自分のメンタルモデルを押し付けないよう人類学者のアプローチのように傾聴することが必要
- 1つのワークフローだけに焦点を当てた短いインタビューを何度も行う
- インタビューの流れ
- 最初はワークフローのインプットとアウトプットのみに焦点を当てる
- 設計に関係のない情報に振り回されることを避ける
- 例: 「オリー、注文確定のプロセスについて教えて。このプロセスは何をきっかけにして開始される?」オリー「お客さんが書いて送ってくるこの注文書から始まります。これをオンラインにしたいんです」
- パッと見るとカタログから商品をカートに入れるようなEコマースモデルに思えても、違うことがある
- 顧客はすでに製品コードを知っていて、コードと数量を書いて注文書を送ってくる運用になっている
- 観察やユーザビリティテストで調査・検証する必要がある
- (デザイン思考や顧客開発のアプローチ)
- 非機能要件の理解
- 誰がどのくらいの頻度で利用しているかなど
- 例: オリー「1000社の顧客がいます。1日あたり200件ほどで、特定の時期に注文が大きく増えることはありません。ユーザーは調達部署のプロフェッショナルで、欲しいものはわかっており、効率的な方法を日々探しています。情報としては営業日の終わりまでに確認できればいいです。スピードよりも一貫性や予測可能性を重視しています」
- 残りのワークフローの理解
- 例: 「1件1件の注文所ではどんなことをしますか?」オリー「まず製品コードを正しいかどうかを確認します。製品が存在しないこともあるので。毎月発行している製品カタログリーフレットに記載があります」
- インプットとアウトプットを考える
- インプットとアウトプットについて学んだことを整理する
- インプットは注文書だとわかった。
- アウトプットはワークフローが生成するイベント
- 他の境界づけられたコンテキストにおけるアクションのトリガーとなるもの
- 例: 「注文が確定した」イベント
- インプットとアウトプットについて学んだことを整理する
- 最初はワークフローのインプットとアウトプットのみに焦点を当てる
データベース駆動設計をしたいという衝動との戦い
- データベース駆動設計をしたいという衝動との戦い
- これをやってしまうと間違いを犯してしまう
- ドメイン駆動設計では、データベースのスキーマではなく、ドメインから設計を導き出すようにする
- ユーザーはデータがどのように永続化されるかを気にしないから
- ドメインを元に設計を始めて、特定のストレージの実装を考慮せずにモデル化する
- これをDDDでは、永続性非依存と呼ぶ
- データベース駆動でのゆがみ
- 例: 注文と見積の違いを無視して
Order
としてしまった場合、請求先住所が注文のみに必要だと後になってわかっても、外部キーでモデル化するのが困難となる- (同じ本でも、購入したくて選んでいるときの
本
と、店舗のスタッフが在庫確認しているときの本
は、同じBook
でも全く違う性質を持つ)
- (同じ本でも、購入したくて選んでいるときの
- 例: 注文と見積の違いを無視して
- とりあえず、データベーススキーマなどの先入観を持たずに観察し、耳を傾けることに集中する
クラス駆動設計をしたいという衝動との戦い
- クラス駆動設計をしたいという衝動との戦い
- ドメインではなくオブジェクトのことを考えると、設計に先入観が入る
- クラス駆動設計はデータベース駆動設計と同じくらい危険
- 例:
Order
とQuote
を分離して、OrderBase
クラスを作ったが、この人工的な基底クラスなので、ドメインエキスパートには伝わらないドメインのわい曲となってしまう
- 例:
- 自分の技術的な考えをドメインに押し付けないこと
ドメインの文書化
- ドメインの文書化
- UMLではなくシンプルなテキストベースの言語を使ってみる
- ワークフローをインプットとアウトプットを中心に文書化し、ビジネスロジックは簡単な擬似コードに留める
-
AND
で両方が必須なことを、OR
でどちらか一方が必須なことを表す
- 例: 「注文確定のワークフロー」
-
注文確定のワークフロー
Bounded context: Order-Taking Workflow: "Place order" triggered by: "Order from received" event (when Quote is note checked) primary input: An order form other input: Product catalog output events: "Order Placed" event side-effects: AN acknowledgment is sent to the customer along with the placed order bounded context: Order-Taking data Order = CustomerInfo AND ShippingAddress AND BillingAddress AND list of OrderLines AND AmountToBill data OrderLine = Product AND Quantity AND Price data CustomerInfo = ??? // 未定 data BillingAddress = ??? // 未定
-
- ドメインをわずかに構造化された方法で示しているだけ
- テキストベースの設計なので、プログラマーではない人を尻込みさせずに一緒に対話しながら作業できる
- (テキストベースの設計 -> ワークフロー図をシームレスに繋ぐ)
- UMLではなくシンプルなテキストベースの言語を使ってみる
受注のワークフローを深掘りする
- 受注のワークフローを深掘りする
- 例: オリー「朝、郵便物が届いたら最初に仕分けをします。注文書を1つの山にしてそれ以外と分けます。注文の方がお金を稼ぐために最優先だからです。そして、それぞれの書類について、見積の欄にチェックがあるか確認し、チェックがあれば見積もり依頼書の山に入れます」
- 開発者は技術的な課題に集中して、すべての要件を平等に扱う傾向がある
- 企業はそうは考えない
- お金を稼ぐことが最優先なので、見積依頼書よりも注文を優先となるように設計すべき
- 例: 「注文書の処理では、お客様の名前、電子メール、発送先住所、請求先住所が有効かどうか専用アプリでまず確認します。その後、注文書に記載されている製品コードが無効ではないかカタログのコピーから調べます。装置はWから始まり、機器はGから始まります。」
- 注文書、見積依頼書、無効な依頼書の3つの山ができる
- イベントストーミングでは見逃していた具体的な作業を深掘りしていく
- 例: 「次に注文数量(OrderQuantity)をチェックします。装置は1個単位で売られますが、機器はkg単位で売られるので少数のときもあります。注文書が検証し終わったらチェックを入れておきます。それから請求額の合計を計算して、合計の欄を埋めます。注文書を2枚コピーして、原本をファイリングして、1枚は発送用のファイルボックスに、1枚は請求用のアウトボックスに入れます。最後に原本をスキャンして確認書に添付して、お客様にメールで返信します。」
複雑さをドメインモデルで表現する
- 複雑さをドメインモデルで表現する
- ワークフローを深掘りするとドメインモデルはさらに複雑になっていく
- 複雑さの理解のために時間を費やすのは、コーディングの最中ではない方がいい
- 制約条件の表現
- 上限下限値や制約を文書化する
- どこまで厳格にするか、新しいタイプの出現にどこまで対応しておくのかという疑問
- 設計が厳格だからといって、実装も必ずしも厳格である必要はないということを覚えておく
-
制約条件の表現
context: Order-Taking data WidgetCode = string starting with "W" then 4 digits data GizmoCode = string starting with "G" then 3 digits data ProductCode = WidgetCode OR GizmoCode data OrderQuantity = UnitQuantity OR KilogramQuantity data UnitQuantity = integer between 1 and 1000 data KilogramQuantity = decimal between 0.05 and 100.00
- 注文のライフサイクルを表現する
- 最初は郵便物そのままの検証されていない状態から始まり、検証されて、価格がつけられる
- (状態が遷移している)
- 状態を表すもっとも簡単な方法は、各フェーズに新しい名前をつけること
-
検証前と検証後の各フェーズの名前をつける
data UnvalidatedOrder = UnvalidatedCustomerInfo AND UnvalidatedShippingAddress AND UnvalidatedBillingAddress AND list of UnvalidatedOrderLine data UnvalidatedOrderLine = UnvalidatedProductCode AND UnvalidatedOrderQuantity data ValidatedOrder = ValidatedCustomerInfo AND ValidatedShippingAddress AND ValidatedBillingAddress AND list of ValidatedOrderLine data ValidatedOrderLIne = ValidatedProductCode AND ValidatedOrderQuantity data PricedOrder = ValidatedCustomerInfo AND ValidatedAShippingAddress AND ValidatedBillingAddress AND list of PricedOrderLine // ValidatedOrderLineと異なる AND AmountToBill // 追加されたもの data PricedOrderLine = ValidatedOrderLine AND LinePrice // 追加されたもの data PlacedOrderAcknowledgement = PricedOrder AND AcknowledgementLetter
- モデルによってビジネスロジックを盛り込むことができる
- 検証されていない注文には価格がないこと
- 検証済みの注文には、一部の明細行だけでなく、すべての明細行が検証されていなければならないこと
-
- 最初は郵便物そのままの検証されていない状態から始まり、検証されて、価格がつけられる
- ワークフローのステップを具体化する
- ワークフローのステップに対して、入出力のアプローチを適用する
- ありうる出力の例
- 「注文を確定した」イベントを発送先/請求先に送信する
- 無効な注文として注文書をまとめ、残りのステップをスキップする
- 個別のサブステップに分割して、ワークフロー全体を擬似コードで表現する
-
ワークフローのステップの疑似コード
Workflow "Place Order" = input: OrderForm output: OrderPlaced event (put on a pile to send to other teams) OR InvalidOrder (put on appropriate pile) // step 1 do ValidateOrder If order is invalid then: add InvalidOrder to pile stop // step 2 do PriceOrder // step 3 do SendAcknowledgementToCustomer // step 4 return OrderPlaced event (if no errors) substep "ValidateOrder" = input: UnvalidatedOrder output: ValidatedOrder OR ValidationError dependencies: CheckProductCodeExists, CheckAddressExists validate the customer name check that the shipping and billing address exist for each line: check product code syntax check that product code exists in ProductCatalog if everything is OK, then: return ValidateOrder else: return ValidationError substep "PriceOrder" = input: ValidatedOrder output: PricedOrder dependencies: GetProductPrice for each line: get the price for the product set the price for the line set the amount to bill ( = sum of the line prices) substep "SendAcknowledgementToCustomer" = input: PricedOrder output: None create acknowledgment letter and send it and the priced order to the customer
- コードのように見えるが、まだドメインエキスパートが読んで確認することができる
-
- ワークフローを深掘りするとドメインモデルはさらに複雑になっていく
まとめ
- まとめ
- 設計中には実装の詳細に入り込まないことが重要
- データベース駆動やクラス駆動にならないこと
- 特定の永続化や特定のコーディング方法を前提としない
- データベース駆動やクラス駆動にならないこと
- 単一に見えていた「注文」が、ライフサイクルを通じて微妙に異なるデータや動作をもつバリエーションとなることがわかった
- 設計中には実装の詳細に入り込まないことが重要
3. 関数型アーキテクチャ
- 関数型アーキテクチャにどうやって変換するか
- 動くスケルトンのような粗いプロトタイプを作成して、システムが全体としてどのように機能するかを示す
- DDDの概念である境界づけられたコンテキストやドメインイベントをソフトウェアに変換する
- ソフトウェアアーキテクチャ自体もドメインである
- ユビキタス言語を用いて話し合うようにする
- サイモン・ブラウンのC4アプローチの用語を使用する例
- システムコンテキスト: システム全体を表す最上位の概念
- コンテナ: システムコンテキストは、複数のコンテナから醸成される
- Webサイト、Webサービス、データベースなどのデプロイ可能な単位
- コンポーネント: 各コンテナは、コードの構造のメインである多数のコンポーネントから構成される
- クラス: 各コンポーネントは、低レベルのメソッドや関数の集合である多数のクラス(関数型アーキテクチャではモジュール)から構成される
- 優れたアーキテクチャの目的の1つは、コンテナ、コンポーネント、モジュール間のさまざまな境界を定義して、新しい要件が発生したときの変更コストを最小限に抑える(変更容易性を高める)こと
自律的なソフトウェアコンポーネントとしての境界づけられたコンテキスト
- コンポーネントとしての境界づけられたコンテキスト
- コンテキストは自律的なサブシステムであり、明確に定義された境界を持つことが重要
-
プロジェクト初期では困難
- ドメインへの解像度が高まるにつれて境界は変化すると想定しておく
- モノリスならリファクタリングが用意なため、最初はモノリスとして構築するのがよい
- 必要に応じて疎結合コンテナにリファクタリングしていく
- マイクロサービスの利点が欠点を上回ると確信できない限り、マイクロサービスに飛び付かないようにする
- 真に疎結合なマイクロサービスアーキテクチャを実現するのは容易ではないから
- マイクロサービスの1つを停止したら他のものが失敗するなら、それは分散モノリスにすぎない
境界づけられたコンテキストのコミュニケーション
- 境界づけられたコンテキストのコミュニケーション
- 例: 受注コンテキストが注文の処理を終えたとき、発送コンテキストに実際に発送するように伝えるにはどうすればいいか
- 受注コンテキストのPlace-Order(注文確定)ワークフローはOrderPlaced(注文が確定した)イベントを発行する
- OrderPlacedイベントはキューか他の方法で発行される
- 発送コンテキストはOrderPlacedをリッスン(監視)する
- イベントを受信するとShipOrder(注文を発送する)コマンドが作成される
- ShipOrderコマンドはShip-Order(注文発送)ワークフローを開始する
- Ship-Orderワークフローが正常に終了すると、OrderShipped(注文が発送された)イベントを発行する
- コンポーネントごとに疎結合な設計とする
- コンテキスト間でのイベント送信方法は、選択するアーキテクチャによって異なる
- キューはバッファリングされた非同期通信に適しているので最初の選択肢になる
- シンプルな関数呼び出しで直接繋げることもできる
- コンポーネントが疎結合になるように設計する限り、今すぐに選択する必要はない
- イベント(OrderPlacedなど)をコマンド(ShipOrder)に変換するハンドラーは、コンテキストの境界にあってもいいし、別のインフラの一部にあってもいい
- 例: 受注コンテキストが注文の処理を終えたとき、発送コンテキストに実際に発送するように伝えるにはどうすればいいか
- 境界づけられたコンテキスト間のデータ転送
- OrderPlacedイベントには確定した完全な注文内容が含められている必要がある
- データが大きすぎてイベントに含められない場合には、代わりに参照となる共有データストレージの位置を示す
- 渡されるデータオブジェクトは、ドメインオブジェクトとは違う
- 表面的には似ているが同じではなく、コンテキスト間のインフラの一部としてシリアライズされて共有される
- これをデータ転送オブジェクト DTOと呼ぶことにする
- OrderDTOは、Order(注文)ドメインオブジェクトとほとんど同じ情報を含むが、異なる構造となっている
- DTO型がドメイン境界となる
- 上流コンテキスト:
ドメイン型 -> ドメイン型からDTOへ -> DTO型 -> シリアライズ -> Json/XML
- 下流コンテキスト:
Json/XML -> デシリアライズ -> DTO型 -> DTO型からドメイン型へ -> ドメイン型
- 上流コンテキスト:
- OrderPlacedイベントには確定した完全な注文内容が含められている必要がある
- 信頼の境界線と検証
- 境界づけられたコンテキストの境界線は、「信頼の境界線」として機能する
- コンテキストの内側のものは信頼され有効だが、外側のものは信頼されないし無効である可能性もある
- ワークフローの最初と最後にゲートを追加して、外の世界との仲介者とする
- 入力ゲート: 入力がドメインモデルの制約に適合しているか常に検証する
- 例: Orderのあるプロパティに非nullで50文字以内の制約があれば、入力されるOrderDTOの時点ではなんでもいいが、入力ゲートで検証した後は有効だと確信できるものとなる。検証が失敗すればワークフローの残りの部分はバイパスされてエラーが生成される
- 出力ゲート: コンテキスト間の偶発的な結合やを避けるためやセキュリティのために、境界づけられたコンテキストの外にプライベートな情報が漏れないようにする
- 例: 注文の支払いのためのクレジットカード情報を発送コンテキストが知る必要はないため、ドメインオブジェクトをDTOに変換する過程で意図的にカード番号などを落とすことがある
- 入力ゲート: 入力がドメインモデルの制約に適合しているか常に検証する
- 境界づけられたコンテキストの境界線は、「信頼の境界線」として機能する
境界づけられたコンテキスト間の契約
- 境界づけられたコンテキスト間の契約
- 契約とは
- イベントとDTOが境界づけられたコンテキスト間の契約のようなものを構成する
- DDD用語
- 共有カーネル関係: 受注コンテキストと発送コンテキストが発送先住所に対して同じ住所を使う場合、受注コンテキストで検証するため共有カーネル関係にある。
- ここで発送コンテキストのイベントまたはDTOの定義を変更する場合は影響を受ける他のコンテキストと協議が必要になる
- 顧客/供給者(コンシューマー駆動契約): 請求書作成コンテキストが契約を定義し、受注コンテキストはその情報のみを提供し、それ以上は提供しない
- 順応者: コンシューマー契約とは逆で、製品カタログによって定義された契約を受注コンテキストが受け入れて、そのまま使用するようにコードを調整する
- 共有カーネル関係: 受注コンテキストと発送コンテキストが発送先住所に対して同じ住所を使う場合、受注コンテキストで検証するため共有カーネル関係にある。
- 腐敗防止層
- 外部のシステムが私たちのドメインモデルとまったく一致しない場合に変換する必要がある
- DDDの用語では腐敗防止層と呼ばれる
- 例: 入力ゲート
- 外部のシステムが私たちのドメインモデルとまったく一致しない場合に変換する必要がある
- 関係を記述したコンテキストマップ
- 受発注BtoBアプリケーションの例
- 受注と発送のコンテキストの関係は共有カーネルとなる
- 注文処理と請求処理の関係はコンシューマー駆動契約となる
- 受注システムと製品カタログの関係は順応者となる
- 外部のアドレスチェックサービスとのやりとりには、明示的な腐敗防止層を入れる
- 組織構造に逆コンウェイ戦略で使用することもある
- 受発注BtoBアプリケーションの例
- 契約とは
境界づけられたコンテキストでのワークフロー
- 境界づけられたコンテキストでのワークフロー
- 関数型アーキテクチャではワークフローはそれぞれ1つの関数にマッピングされる
- 入力はコマンドオブジェクトであり、出力はイベントオブジェクトのリストとなる
- ワークフローのインプットとアウトプット
- ワークフローへの入力は、常にコマンドに関連するデータである
- 出力は常に、他のコンテキストに伝えるためのイベントのセット
- ワークフロー関数ではドメインイベントを発行するのではなく、単に返すだけとする
- イベントの発行やどう伝えるかは別に取り扱う課題となる
- 例: 注文確定のワークフローでは、入力はPlaceOrder(注文を確定する)コマンドに関連するデータであり、出力はOrderPlaced(注文が確定した)イベントなどのイベントのセット
- 境界づけられたコンテキスト内ではドメインイベントを避ける
- 関数型アーキテクチャではワークフローはそれぞれ1つの関数にマッピングされる
境界づけられたコンテキストの中のコード構造
- 境界づけられたコンテキストの中のコード構造
- どういったアーキテクチャがいいか
- ドメインコードを中心において、各レイヤーは内側のレイヤーのみに依存するように配置する
- ワークフローが貫くようなオニオンアーキテクチャ・ヘキサゴナルアーキテクチャ・クリーンアーキテクチャなどのアプローチがある
- I/Oを端に追いやる
- 関数型プログラミングの主な目的は、内部を見なくても予測可能で理解しやすくするため
- そのために、隠れた依存関係ではなく明示的な依存関係を持つようにする
- ランダム性、関数外の変数の書き換え、I/Oを避ける
- あらゆるI/Oをオニオンの端に追いやること
- データベースへのアクセスは、ワークフローの内部ではなく、ワークフローの開始時または終了時にのみ行うようにする
- コアドメインモデルはビジネスロジックだけを対象とし、永続性などのI/Oはインフラストラクチャの関心事にする
- 関数型プログラミングの主な目的は、内部を見なくても予測可能で理解しやすくするため
- どういったアーキテクチャがいいか
まとめ
- DDD関連の概念や用語
- ドメインオブジェクト: DTOとは対照的に、あるコンテキストの境界内でのみ使用するように設計されたオブジェクト
- データ転送オブジェクト(DTO): シリアライズされ、コンテキスト間で共有されるように設計されたオブジェクト
- 共有カーネル、顧客/供給者、順応者: 境界づけられたコンテキスト間の関係性の種別
- 腐敗防止層: 結合を減らしてドメインが独立して進化できるように、あるドメインから別のドメインに概念を変換するコンポーネント
- 永続性非依存: ドメインモデルはドメイン自体の概念のみ基づき、データベースやその他の永続性メカニズムを意識するべきではないこと
次にやること
- 型システムを使用してワークフローと使用するデータを定義し、ドメインエキスパートや非開発者でも理解できるコンパイル可能なコードを作成していく
- OOPでのクラスと関数型プログラミングの型の違いを理解する
第二部: ドメインのモデリング
- 第2部の内容
- ドメインを関数的に分解していくアプローチが、オブジェクト指向とどのように違うのか見ていく
- 型を活用して要件を表現する方法を学ぶ
- ドメインに関する読みやすいドキュメントであり、かつそれだけでもコンパイル可能なコードの書き方を学ぶ
4. 型の理解
- 型の理解
- ドメイン駆動で要件を表現したものを、コンピューターがコンパイルできるコードに変換していく
- 代数的な型システムによって要件を記述するアプローチ
関数の理解
- 関数の理解
- 関数とは入力と出力を持つブラックボックスのようなもの
- 例: りんごをバナナに変える関数は、
apple -> banana
という記法となる
- 例: りんごをバナナに変える関数は、
- 型シグネチャ
-
型シグネチャ
let add1 x = + 1 // 型シグネチャ: int -> int let add x y = x + y // 型シグネチャ: int -> int -> int
-
- ジェネリック型の関数
- アポストロフィが文字の先頭についたものはジェネリック型を表す
-
ジェネリック型
// areEqual : 'a -> 'a -> bool let areEqual x y = (x = y)
- 関数とは入力と出力を持つブラックボックスのようなもの
型と関数
- 型と関数
- 関数型プログラマーが言う型とは何か
- 関数の入力または出力として使用可能な値の集合に与えられた名前にすぎない
- OOPでいうクラスとは異なる
-
int16
でもstring
でもPerson
という集合でも、関数の集合でもよい
- 関数の入力または出力として使用可能な値の集合に与えられた名前にすぎない
- 専門用語
- 値: 入出力として使用できる型の一員で、動作が付随していない不変なデータ(変数ではない)
- オブジェクト: データ構造とそれに付随する動作(メソッド)をカプセル化したもの
- 関数型プログラマーが言う型とは何か
型の合成
- 型の合成
- 合成(コンポジション)とは、レゴブロックのように組み合わせてより大きなものを作ること
- 直積型(
AND
)や直和型・選択型(OR
)で小さな型から新しい型を作れる
- 直積型(
-
ANDとOR
type FruitSalad = { Apple: AppleVariety Banana: BananaVariety Cherries: CherryVariety } type FruitSnack = | Apple of AppleVariety | Banana of BananaVariety | Cherries of CherryVariety
- 単純型
- プリミティブを内部の値として含むラッパー型を作ることができる
type ProductCode = ProductCode of string
- 代数的な型システムとは
- より小さな型を
AND
またはOR
で合成してできているもの
- より小さな型を
- 合成(コンポジション)とは、レゴブロックのように組み合わせてより大きなものを作ること
F#の型を扱う
- F#の型を扱う
- F#は型の定義方法と値の構築方法が似ている
- 型の定義:
type Person = {First: string; Last: string}
- 値の構築:
let aPerson = {First="Alex"; Last="Adams"}
- パターンマッチによる型の値の分解:
let {First=first; Last=last} = aPerson
- ドット構文:
let first = aPerson.First
- 型の定義:
- ケースラベルをコンストラクタ関数として使用する
-
ケースラベルをコンストラクタ関数として使用する
type OrderQuantity = | UnitQuantity of int | KilogramQuantity of float let anOrderQtyInUnits = UnitQuantity 10 let anOrderQtyInKg = KilogramQuantity 2.5
- UnitQuantityやKilogramQuantityは型そのものではなく、OrderQuantityという型に属する別々のケースにすぎない
-
- 選択型をパターンマッチで分解する
-
選択型をパターンマッチで分解する
let printQuantity aOrderQty = match aOrderQty with | UnitQuantity uQty -> printfn "%i units" uQty | KilogramQuantity kgQty -> printfn "%g kg" kgQty printQuantity anOrderQtyInUnits // "10 units" printQuantity anOrderQtyInKg // "2.5 kg"
-
- F#は型の定義方法と値の構築方法が似ている
型の合成によるドメインモデルの構築
- 型の合成によるドメインモデルの構築
- 型をさまざまな組み合わせで合成して、複雑なモデルを素早く作成できる
- 例: Eコマースサイトの支払い
- まずはCheckNumber(小切手番号)のようなプリミティブ型のラッパーから始める
-
プリミティブ型のラッパー
type CheckNumber = CheckNumber of int type CardNumber = CardNumber of string
-
- 次に低レベルの型をいくつか構築する
-
次に低レベルの型
type CardType = Visa | MasterCard // 'OR'型 type CreditCardInfo = { // 'AND'型 CardType : CardType CardNumber : CardNumber }
-
- 支払いの型を作っていく
-
支払いの型
type PaymentMethod = | Cash | Check of CheckNumber | Card of CreditCardInfo type PaymentAmount = PaymentAmount of decimal type Currency = EUR | USD type Payment = { Amount : PaymentAmount Currency : Currency Method : PaymentMethod }
-
- Payment型を使って未払いの請求書の支払い処理をして、支払い済みの請求書になる方法を型で定義する
-
Payment型を使用した支払い処理の定義
type PayInvoice = UnpaidInvoice -> Payment -> PaidInvoice type ConvertPaymentCurrency = Payment -> Currency -> Payment
-
- まずはCheckNumber(小切手番号)のようなプリミティブ型のラッパーから始める
省略可能な値、エラー、およびコレクションのモデリング
- 省略可能な値、エラー、およびコレクションのモデリングをどうF#で表現するか
- 省略可能な値のモデリング
- レコード型や選択型はF#では値をnullにできない
- では欠落したデータや省略されたデータをどうモデル化するか
- 欠落したデータの意味を考えることで解決する
- 存在か不在かなのでOptionを使う
-
Optionを使用してモデル化する
type Option<'a> = | Some of 'a | None type PersonalName = { FirstName : string MiddleInitial : Option<string> LastName : string } type PersonalName = { FirstName : string MiddleInitial : string option // F#では後に書くこともできる LastName : string }
- エラーのモデリング
- 失敗の可能性があるプロセスはどうモデル化するか
- 失敗が起こりうるということをResultで明示的に表現する
-
Resultを使用してモデル化する
type Result<'Success, 'Failure> = | Ok of 'Success | Error of 'Failure type PayInvoice = UnpaidInvoice -> Payment -> Result<PaidInvoice, PaymentError> type PaymentError = | CardTypeNotRecognized | PaymentRejected | PaymentProviderOffline
-
- 値が存在しないことのモデリング
- 関数型言語ではすべての関数は何かを返さなければならないので、voidは使えない
- unitという特別な組み込み型を使う
-
unitを使用してモデル化する
type SaveCustomer = Customer -> unit type NextRandom = unit -> int
-
- シグネチャの中にunit型がある場合、それは副作用があることを強く示している
- どこかに状態変更が発生しているはずだから
- リストとコレクションのモデリング
- F#でのさまざまなコレクション型
- list: 固定サイズの不変コレクション(リンクリストとして実装されている)
- array: 固定サイズの可変コレクション
- ResizeArray: 可変サイズの配列(C#のList<T>型のエイリアス)
- seq: 遅延コレクション(C#のIEnumerable<T>型のエイリアス)
- ドメインモデリングでは常にlist型を使用する
-
listを使用してモデル化する
type Order = { OrderId: OrderId Lines: OrderLine list } let aList = [1;2;3] let aNewList = 0 :: aList // 新しいリストは[0;1;2;3] let printList1 aList = match aList with | [] -> printfn "list is empty" | [x] -> printfn "list has one element: %A" x | [x;y] -> // ここまでは、リテラルを使ったマッチング printfn "list has two elements: %A and %A" x y | lingerList -> // リテラル以外を使ったマッチング printfn "list has more than two elements" let printList2 aList = // "cons"にマッチ match aList with | [] -> printfn "list is empty" | first::rest -> printfn "list is non-empty with the first element being %A" first
-
- F#でのさまざまなコレクション型
- 省略可能な値のモデリング
ファイルやプロジェクトでの型の整理
- ファイルやプロジェクトでの型の整理
- F#には宣言の順番について厳しいルールがある
- ファイル上部にある型は、ファイルの下部にある別の型を参照できない
- 標準的なアプローチ
- まずすべてのドメイン型をTypes.fsやDomain.fsなどの1ファイルにまとめる
- それに依存する関数は後ろに置いて、コンパイル順に並べる
-
型を書く順番の例
module Payments = // ファイル上部には単純型 type CheckNumber = CheckNumber of int // ファイル中部にはドメイン型 type PaymentMethod = | Cash | Check of CheckNumber // 上で定義済み | Card of ... // ファイル下部にはトップレベル型 type Payment = { Amount: ... Currency: ... Method: PaymentMethod //上で定義済み }
- recキーワードを使用するとモジュール内のどこでも型同士を参照できるようになる
- 設計が落ち着いて本番稼働の準備が整ったら、型を正しい順序に置くほうがよい
- 可読性が上がるため
- 設計が落ち着いて本番稼働の準備が整ったら、型を正しい順序に置くほうがよい
- F#には宣言の順番について厳しいルールがある
まとめ
- 型の概念と関数型プログラミングとの関係を見てきた
- 小さな型からより大きな型を作るための型の合成方法
- ANDのレコード型や、ORで合成した選択型(判別共用体)がある
- OptionやResultなどの一般的な型
5. 型によるドメインモデリング
- 共有されたモデルがコードにも反映されている必要がある
- これができていると、開発者がドメインモデルをコードに翻訳する必要はなくなる
- ドメインの概念をゆがめてしまうことがない
- 理想としては、ソースコードがドキュメントとしても機能すること
- UML図などが必要ない状態
- ドメインエキスパートや他の非開発者がコードをレビューしたり設計を確認したりできるようになる状態が望ましい
- これができていると、開発者がドメインモデルをコードに翻訳する必要はなくなる
- そんなことが可能なのか?
- 型によってドキュメントを置き換えながら設計をコード自体で表現し、設計と実装がずれないようにすれば可能となる
ドメインモデルの見直し
- ドメインモデルの見直し
- 今までで作成したモデル
-
今までで作成したドメインモデル
context: Order-Taking // ---------------------- // 単純型 // ---------------------- // 製品コード data ProductCode = WidgetCode OR GizmoCode data WidgetCode = string starting with "W" then 4 digits data GizmoCode = ... // 注文数量 data OrderQuantity = UnitQuantity OR KilogramQuantity data UnitQuantity = ... data KilogramQuantity = ... // ---------------------- // 注文のライフサイクル // ---------------------- // ----- 未検証の状態 ----- data UnvalidatedOrder = UnvalidatedCustomerInfo AND UnvalidatedShippingAddress AND UnvalidatedBillingAddress AND list of UnvalidatedOrderLine data UnvalidatedOrderLine = UnvalidatedProductCode AND UnvalidatedOrderQuantity // ----- 検証済みの状態 ----- data ValidateOrder = ... data ValidatedOrderLine = ... // ----- 価格計算済みの状態 ----- data PricedOrder = ... data PricedOrderLine = ... // ----- 出力イベント ----- data OrderAcknowledgmentSent = ... data OrderPlaced = ... data BillableOrderPlaced = ... // ---------------------- // ワークフロー // ---------------------- workflow "Place Order" = input: UnvalidatedOrder output: (on success): OrderAcknowledgmentSent AND OrderPlaced (to send to shipping) AND BillableOrderPlaced (to send to billing) output: (on error): InvalidOrder // etc
-
- 今までで作成したモデル
ドメインモデルのパターンを見る
- ドメインモデルのパターンを見る
- 単純な値: stringやintなどのプリミティブ型で表される基本的な構成要素。ドメインエキスパートはOrderedId(注文ID)などのユビキタス言語の概念で考えることに注意
- ANDによる値の組み合わせ: 名前・住所・注文などの、紙ベースの世界では書類そのものやそこに含まれるひとまとまりの要素
- ORによる選択肢: UnitQuantity(ユニット数)やKilogramQuantity(キログラム量)など
- ワークフロー: インプットとアウトプットを持つビジネスプロセス
単純な値のモデリング
- 単純な値のモデリング
- ドメインエキスパートはstringやintではなく、OrderId(注文ID)やProductCode(製品コード)などのドメイン概念で考えている
- 同じintでもOrderIdとProductCodeはまったく違うものなので、混同しないようにする必要がある
- ラッパー型で表現する
- 選択肢が1つしかない選択型を作成する
-
選択肢が1つしかない選択型
type CustomerId = CustomerId of int type WidgetCode = WidgetCode of int type UnitQuantity = UnitQuantity of int type KilogramQuantity = KilogramQuantity of decimal
- 利用方法
-
ラッパー型の利用方法
let customerId = CustomerId 42 let orderId = OrderId 42 // 比較しようとするとコンパイルエラー printfn "%b" (orderId = customerId) //^ This expression was expected to have type 'OrderId' but here has type 'CustomerId' // CustomerIdを受け取る関数を定義 let processCustomerId (id:CustomerId) = ... // OrderIdをを渡すとコンパイルエラー processCustomerId orderId // ^ This expression was expected to have type 'CustomerId' but here has type 'OrderId' // 分解 let processCustomerId (CustomerId innerValue) = printfn "innerValue is %i" innerValue // 関数のシグネチャ val processCustomerId: CustomerId -> unit
-
- ドメインエキスパートはstringやintではなく、OrderId(注文ID)やProductCode(製品コード)などのドメイン概念で考えている
複雑なデータのモデリング
- 複雑なデータのモデリング
- レコード型によるモデリング
-
作成されたドメインモデルを変更した型構造
// 作成したドメインモデル data Order = CustomerInfo AND ShippingAddress AND BillingAddress AND list of OrderLines AND AmountToBill // 変換されたレコード構造 type Order = { CustomerInfo : CustomerInfo ShippingAddress : ShippingAddress BillingAddress : BillingAddress OrderLines : OrderLine list AmountToBill : ... }
-
- ドメインモデルに名前と型を与えると、ドメインに関する多くの未解決の問題が見えてくる
- ドメインエキスパートと協力する
- 専門家が請求先住所と発送先住所を別のものとして話しているなら、同じ構造でも論理的に分離しておく方がいい
- ドメインの理解が進んだり、要件が変わると異なる方向に進化する可能性がある
- 未知の型のモデリング
- 設計初期では、ユビキタス言語のおかげでモデル化する必要のある型の名前はわかっても内部構造がわからないということがよくある
- 悩む必要はない
- 明示的に未定義の型としてモデル化したり、プレースホルダーとしておいておいてもいい
- より解像度が上がったときに置き換えればいいから
- 例外型のexnを使って、Undefined(未定義)とエイリアスすると、コードはコンパイルできる
-
未知の型を定義する
type Undefined = exn type CustomerInfo = Undefined type ShippingAddress = Undefined ... type Order = { CustomerInfo : CustomerInfo ShippingAddress : ShippingAddress ... }
-
- 設計初期では、ユビキタス言語のおかげでモデル化する必要のある型の名前はわかっても内部構造がわからないということがよくある
- 選択型によるモデリング
- 選択肢は選択型によってモデリングする
-
選択型によるモデリング
// ドメインモデル data ProductCode = WidgetCode OR GizmoCode data OrderQuantity = UnitQuantity OR KilogramQuantity // モデリングした型 type ProductCode = | Widget of WidgetCode // ofの前にあるラベルは型名と一致しなくてもよい | Gizmo of GizmoCode type OrderQuantity = | Unit of UnitQuantity | Kilogram of KilogramQuantity
- レコード型によるモデリング
関数によるワークフローのモデリング
- 関数によるワークフローのモデリング
- 動詞であるビジネスプロセスは関数型としてモデル化する
-
ビジネスプロセスは関数型としてモデル化する
type ValidateOrder = UnvalidatedOrder -> ValidatedOrder
-
- 複雑な入力と出力の処理
- ワークフローの中に複数の入力と出力があった場合はどうするか
- 出力について
- outputAとoutpoutBの両方を出力する場合
- その両方を格納できるレコード型を作成する
-
ワークフローがoutputAとoutpoutBの両方を出力する場合
type PlaceOrderEvents = { AcknowledgmentSent : AcknowledgmentSend OrderPlaced : OrderPlaced BillableOrderPlaced : BillableOrderPlaced } type PlaceOrder = UnvalidatedOrder -> PlaceOrderEvents
- outputAかoutputBのどちらかを出力する場合
- どちらでも格納できる選択型を作成する
-
ワークフローがoutputAかoutputBのどちらかを出力する場合
// 複数の出力があるワークフロー workflow "Categorize Inbound Mail" = input: Envelope contents output: QuoteForm (put on appropriate pile) OR OrderFrom (put on appropriate pile) OR ... // 出力 type EnvelopeContents = EnvelopeContents of stringe type CategorizedMail = | Quote of QuoteForm | Order of OrderForm ... type CategorizeInboundMail = EnvelopeContents -> CategorizedMail
- outputAとoutpoutBの両方を出力する場合
- 入力について
- inputAとinputBが入力される場合
- inputAとinputBが依存関係にある場合は別々のパラメーターとして渡す
- これで依存関係の注入と同じように使えるようになる
- inputAとinputBの両方の入力が常に必要で互いに強く結びついている場合は、レコード型を使用してそれを明示的にする
-
ワークフローにinputAとinputBが入力される場合
// 複数の入力があるワークフロー "Calculate Prices" = input: OrderForm, ProductCatalog output: PricedOrder // 各入力を別々のパラメータとして渡す例 type CalculatePrices = OrderForm -> ProductCatalog -> PricedOrder // 入力をまとめて渡す例 type CalculatePricesInput = { OrderForm : OrderForm ProductCatalog : ProductCatalog } type CalculatePrices = CalculatePricesInput -> PricedOrder
- inputAとinputBが依存関係にある場合は別々のパラメーターとして渡す
- inputAとinputBが入力される場合
- 出力について
- ワークフローの中に複数の入力と出力があった場合はどうするか
- 関数のシグネチャでエフェクトを文書化する
- 関数型プログラミングでは、関数が主な出力以外に行うことをエフェクトと呼ぶ
- 検証が失敗する可能性をResult型で表現する
- エラーエフェクトを持つ
-
Result型で検証失敗可能性を明示する
type ValidateOrder = UnvalidatedOrder -> Result<ValidatedOrder, ValidationError list> and ValidationError = { FieldName : string ErrorDescription : string }
- これで関数が常に成功するとは限らないことが明確になる
- エラー処理をコンパイラで強制できる
- プロセスが非同期であることを文書化するには?
- Asyncを使う
-
Asyncを使って非同期を表現する
type ValidationResponse<'a> = Async<Result<'a, ValidationError list>> type ValidateOrder = UnvalidatedOrder -> ValidationResponse<ValidatedOrder>
- 動詞であるビジネスプロセスは関数型としてモデル化する
アイデンティティの考察: 値オブジェクト
- アイデンティティの考察: 値オブジェクト
- 値オブジェクト
- データ型を永続的なIDを持つかどうかで分類していく
- DDDの用語
- エンティティ: 永続的なアイデンティティを持つオブジェクト
- 値オブジェクト: 永続的なアイデンティティを持たないオブジェクト
- 例: 値がW1234であるWidgetCode(装置コード)の1つのインスタンスは、値がW1234である他のどのWidgetCodeと同じ
-
同一の値オブジェクト
let widgetCode1 = WidgetCode "W1234" let widgetCode2 = WidgetCode "W1234" printfn "%b" (widgetCode1 = widgetCode2) // true
- 単純型でも複雑型でも値オブジェクトであり、値が同じであれば同一
- ドメイン内の値オブジェクトであることがわかる会話内容
- "Chris has the same name as me."
- クリスと私は違う人間なのに、名前が同じ
- 要するに、「名前」自体には固有のアイデンティティはないということ
- DDDの用語
- 値オブジェクトの等値性の実装
- F#はデフォルトでフィールドベースの等値性テストを実装してくれる
- 構造的等値性
- F#はデフォルトでフィールドベースの等値性テストを実装してくれる
- データ型を永続的なIDを持つかどうかで分類していく
- 値オブジェクト
アイデンティティの考察: エンティティ
- アイデンティティの考察: エンティティ
- エンティティ
- エンティティとは
- 構成要素が変化しても固有のアイデンティティを持つもの
- 私の名前や住所が変わっても、私は同じ人間であることに変わりはない
- ビジネスの現場では、注文書・見積書・請求書・顧客プロファイル・製品シートなどの何らかの文書であることが多い
- エンティティはライフサイクルを持つ
- ある状態から別の状態へと変換される
- 値オブジェクトとエンティティの区別は文脈に依存する
- 例: 携帯電話のライフサイクルでは、製造段階ではシリアル番号で固有のアイデンティティを持つが、販売時にはシリアル番号はない。同じ仕様の携帯電話は全て互換性があり、値オブジェクトとしてモデル化できる。さらに、特定の顧客に販売されると画面やバッテリーを交換しても同じ携帯電話だと考えられるため、エンティティとしてモデル化すべきものとなる。
- 構成要素が変化しても固有のアイデンティティを持つもの
- エンティティの識別子
- 注文IDや顧客IDといった一意の識別子やキーを与える
- 識別子はどこから来るのか?
- 紙の注文書や請求書に参照番号が書かれていることもある
- UUIDやDBテーブルやID生成サービスで作成することもある
- データ定義への識別子の追加
- 型の定義に識別子を追加するには
- レコード型にはフィールドを追加すればいい
- 選択型はどうすればいいか?
- 識別子を内側と外側のどちらにおくべきか
- 外側のアプローチでモデル化した場合
-
外側のアプローチでモデル化した場合
// 未支払いのケースの情報(ID無し) type UnpaidInvoiceInfo = ... // 支払い済みのケースの情報(ID無し) type PaidInvoiceInfo = ... // 結合された情報(ID無し) type InvoiceInfo = | Unpaid of UnpaidInvoiceInfo | Paid of PaidInvoiceInfo // 請求書のID type InvoiceId = ... // トップレベルの請求書型 type Invoice = { InvoiceId : InvoiceId // 子のケースの外側 InvoiceInfo : InvoiceInfo }
- 問題点: 1のケースのデータが型のさまざまな部分に分散しているため、簡単には処理できない
-
- 内側のアプローチでモデル化した場合
- 一般的にはこちらが多い
-
内側のアプローチでモデル化した場合
type UnpaidInvoice = { InvoiceId : InvoiceId // 内側に保持するID ... } type PaidInvoice = { InvoiceId : InvoiceId // 内側に保持するID ... } // トップレベルの請求書型 type Invoice = { | Unpaid of UnpaidInvoice | Paid of PaidInvoice } // パターンマッチの際にIDを含めてすべてのデータに一度にアクセスできる let invoice = Paid {InvoiceId = ...} match invoice with | Unpaid unpaidInvoice -> printfn "The unpaid invoiceId is %A" unpaidInvoice.InvoiceId | Paid paidInvoice -> printfn "The paid invoiceId is %A" paidInvoice.InvoiceId
- 型の定義に識別子を追加するには
- エンティティに対する等値性の実装
- エンティティを比較する際には識別子というフィールドだけを使用したい
- F#のデフォルトの動作を変更する必要がある
- 等値性テストをオーバーライドして識別子のみを使用する
- エンティティを比較する際には識別子というフィールドだけを使用したい
- 不変性とアイデンティティ
- 関数型プログラミングではオブジェクトは不変なので、初期化された後は変更ができない
- 設計ではどういう影響があるか
- 値オブジェクト: 不変性が求められる
- エンティティ: 時間とともに変化することが予想されるのため、変化しない識別子を持つ必要がある。不変のデータ構造として動作させるには、エンティティのコピーを作成するようにする
-
エンティティを更新する例
let initialPerson = {PersonId=PersonId 42; Name="Joseph"} // 一部のフィールドだけ変更してコピーを作成する let updatedPerson = {initialPerson with Name="Joe"} // 変更があった場合を型シグネチャで明示できる type UpdatedName = Person -> Name -> Person
-
- エンティティとは
- エンティティ
集約
- 集約
- 集約について
- 受発注BtoBアプリケーションのデータ型はどうなるか
- Order(注文): 注文の詳細は時間とともに変わるかもしれないが、注文自体は変わらないのでエンティティとなる
- OrderLine(注文明細業): 注文明細行の数量を変更したとしても依然として同じ注文明細行なのでエンティティとなる
- 注文明細行を変更した場合、注文も変更したことになる
- ある低レベルのコンポーネントを変更すると、より高レベルのコンポーネントにも変更が加えられる
- サブエンティティの1つの変更でも、常にOrder自体のレベルで作業しなければならない
- DDDの用語でこのようなエンティティのコレクションを集約と呼び、トップレベルのエンティティを集約ルートと呼ぶ
- 集約: OrderとOrderLinesのコレクションの両方から構成される
- 集約ルート: Orderそのもの
-
注文明細行の価格を更新する疑似コード
/// 3つのパラメーターを渡す /// * トップレベルの注文 /// * 変更したい注文明細行のID /// * 新しい価格 let changeOrderLinePrice order orderLineId newPrice = // 1. orderLineIdを使用して変更する行を検索する let orderLine = order.OrderLines |> findOrderLine orderLineId // 2. 新しい価格を持つ、新しいバージョンの注文明細行を作成すr let newOrderLine = {orderLine with Price = newPrice} // 3. 古い明細行を新しい明細行で置き換えた、 // 新しい明細行リストを作成する let newOrderLines = order.OrderLines |> replaceOrderLine orderLineId newOrderLine // 4. すべての古い明細行を新しい明細行で置き換えた、 // 新しいバージョンの注文を作成する let newOrder = {order with OrderLines = newOrderLines} // 5. 新しい注文を返す newOrder
- ある低レベルのコンポーネントを変更すると、より高レベルのコンポーネントにも変更が加えられる
- 受発注BtoBアプリケーションのデータ型はどうなるか
- 集約による整合性と不変条件の担保
- 集約はデータが更新されたときに、整合性の境界として重要な役割を果たす
- ある部分が更新されると他の部分も更新する必要がある
- 例: トップレベルのOrderに合計金額を追加すると、明細行の1つの価格を変更した場合に合計も更新する必要がある。また、すべての注文に最低1つの注文明細行が必要だと、注文明細行がすべて削除される場合に集約がエラーを発生させる。
- 集約はデータが更新されたときに、整合性の境界として重要な役割を果たす
- 集約の参照
- 顧客に関する情報をOrderに関連づける場合はどうするか
- CustomerとOrderは別個で独立した集約
- それぞれ独自の内部整合性に責任を持っている
- 各フィールドの唯一の接続はルートオブジェクトの識別子
- 顧客のレコード自体ではなく、顧客への参照を格納する
-
参照で関連づける
type Order = { OrderId : OrderId CustomerId : CustomerId OrderLines : OrderLine list ... }
- CustomerとOrderは別個で独立した集約
- データベースからオブジェクトをロードまたはセーブしたい場合は、集約全体をロードまたはセーブすべき
- 各データベーストランザクションは単一の集約を扱うべき
- 集約の境界を越えてはいけない
- オブジェクトをシリアライズしてネットワーク送信するときは、必ず集約全体を送信する
- ドメインモデルにおける集約の役割
- トップレベルのエンティティがルートとして機能し、単一のユニットとして扱えるドメインオブジェクトのコレクション
- 集約内のオブジェクトに対するすべての変更はトップレベルエンティティを起点にする
- 集約は、永続化、データベーストランザクション、データ転送におけるアトミックな(すべて実行されるか、何も実行されないかのどちらかになる)処理単位
- 顧客に関する情報をOrderに関連づける場合はどうするか
- DDDの用語
- 値オブジェクト: アイデンティティを持たないドメインオブジェクト
- 例: 名前、住所、場所、金額、日付
- エンティティ: 一意な識別子を持ち、プロパティが変更されても識別子によって持続するドメインオブジェクト
- 例: 顧客、注文、製品、請求書
- 集約: トップレベルのルートの一意な識別子で関連するオブジェクトの集まり
- 値オブジェクト: アイデンティティを持たないドメインオブジェクト
- 集約について
すべてを組み合わせる
- すべてを組み合わせる
- 名前空間に入れて組み合わせる
-
名前空間に入れて組み合わせる
namespace OrderTaking.Domain // 型の定義 // 値オブジェクト // 製品コード関連 type WidgetCode = WidgetCode of string // 制約: 先頭が"W"+数字4桁 type GizmoCode = GizmoCode of string // 制約: 先頭が"G"+数字4桁 type ProductCode = | Widget of WidgetCode | Gizmo of GizmoCode // 注文数量関連 type UnitQuantity = UnitQuantity of int type KilogramQuantity = KilogramQuantity of decimal type OrderQuantity = | Unit of UnitQuantity | Kilogram of KilogramQuantity // エンティティ type OrderId = Undefined // IDがstringかintかGuidなのかはまだ決まっていない type OrderLineId = Undefined type CustomerId = Undefined type CustomerInfo = Undefined type ShippingAddress = Undefined type BillingAddress = Undefined type Price = Undefined type BillingAmount = Undefined type Order = { Id : OrderId // エンティティのID CustomerId : CustomerId // 顧客の参照 ShippingAddress : ShippingAddress BillingAddress : BillingAddress OrderLines : OrderLine list AmountToBill : BillingAmount } and OrderLines = { Id : OrderLineId // エンティティのID OrderId: OrderId ProductCode : ProductCode OrderQuantity : OrderQuantity Price : Price } // ワークフロー type UnvalidatedOrder = { OrderId : string CustomerInfo : ... ShippingAddress : ... ... } type PlaceOrderEvents = { AcknowledgmentSent : ... OrderPlaced : ... BillableOrderPlaced : ... } type PlaceOrderError = | ValidationError of ValidationError list | ... その他のエラー and ValidationError = { FieldName : string ErrorDescription : string } // 「注文確定」プロセス type PlaceOrder = UnvalidatedOrder -> Result<PlaceOrderEvents, PlaceOrderError>
-
- 型はドキュメントの代わりになるか?
- 上記のドメインモデルはF#の型だが、以前にANDやOR記法を使って作成したドメインのドキュメントとほとんど同じ
- 開発者でなくとも単純型、AND型、OR型、プロセスの構文を学べば読める
- 名前空間に入れて組み合わせる
まとめ
- ユビキタス言語から、F#の型システムを使って単純型、レコード型、選択型でドメインをモデル化できた
- ProductCode(製品コード)やOrderQuantity(注文数量)などのユビキタス言語を使っているので開発者でなくとも読める
- ManagerやHandlerなどのような型がない
- さらにこれはコンパイル可能なコードとなっている
- 設計がコードそのものとなっているということ
- 型をドキュメントとして使用できる
- ドメインエキスパートと共同して、アイデアをすぐにコードに反映できる
- ドメインエキスパートでもただのテキストなのでレビューできるということ
- ドメインエキスパート自身が書くこともできるようになっている
- (コードを通じて開発者とシームレスに連携できる)
- ProductCode(製品コード)やOrderQuantity(注文数量)などのユビキタス言語を使っているので開発者でなくとも読める
- 値オブジェクト・エンティティ・整合性を確保するための集約という概念を学んだ
- 次章で学ぶこと
- 単純型の制約をどう書けばいいか
- 集約の整合性をどうやって確保するか
- 注文の状態変化をどうモデル化するか
6. ドメインの完全性と整合性
- ドメインをモデル化できたら、次はこのドメイン内のデータが有効で整合性があることを保証したい
- 予防策をいくつか講じる
- 常に信頼できるデータだけの境界づけられたコンテキストを作る
- 完全性(妥当性)と整合性のモデリングを見ていく
- 完全性とは
- UnitQuantity(ユニット数)は1から1000個の間であるべき
- 注文には常に、明細行が少なくとも1つ必要である
- 注文は、発送部門に送られる前に、発送先住所を検証しておく必要がある
- 整合性とは
- 注文の合計金額は、個々の行の合計と同じでなければならない
- 合計が異なる場合はデータが矛盾していることになる
- 注文が確定するとそれに対応する請求書が作成されなければならない
- 注文は存在するのに請求書が存在しないデータは矛盾している
- 注文時にクーポンコードを使用した場合、クーポンコードは使用済みになっていなければならない
- 注文の合計金額は、個々の行の合計と同じでなければならない
- 完全性とは
- 型システムで多くの情報が得られればドキュメントが少なくて済むし、コードが正しく実装される可能性が高くなる
単純な値の完全性
- 単純な値の完全性
- stringやintをWidgetCodeやUnitQuantityのようなドメインに特化した型で表現するだけでは足りない
- 現実のドメインでは、境界のない整数や文字列を持つことはまれ
- 何らかの制約を持つことがほとんど
- UnitQuantityはビジネスにおいてマイナスや40億にしたいことはあまりないはず
- CustomerNameは文字列かもしれないが、タブや改行を含むべきではない
- 制約はどう表現するか
- コメントではなく、制約を満たさない限り型が作成できないようにする
- データは不変なため、内部の値を再度チェックする必要がなくなる
- スマートコンストラクタアプローチ
- コンストラクタをプライベートにして、作成時に無効な値を拒否してエラーを返すようにする
- (TypeScripであればzodを使用できる)
-
スマートコンストラクタアプローチ(実際にはヘルパーを使用して繰り返しを減らす)
type UnitQuantity = private UnitQuantity of int // 型と同じ名前のモジュールを定義する module UnitQuantity = /// 単位数の「スマートコンストラクタ」を定義する /// int -> Result<UnitQuantity, string> let create qty = if qty < 1 then Error "UnitQuantity can not be negative" else if qty > 1000 then Error "UnitQuantity can not be more than 1000" else Ok (UnitQuantity qty) let value (UnitQuantity qty) = qty let unitQty = UnitQuantity 1 // コンパイルエラー let unitQtyResult = UnitQuantity.create 1 match unitQtyResult with | Error msg -> printfn "Failure, Message is %s" msg | Ok uQty -> printfn "Success. Value is %A" uQty let innerValue = UnitQuantity.value uQty printfn "innerValue is %i" innerValue
- コメントではなく、制約を満たさない限り型が作成できないようにする
- stringやintをWidgetCodeやUnitQuantityのようなドメインに特化した型で表現するだけでは足りない
測定単位
- 測定単位
- 数値の場合は、測定単位を使用するアプローチがある
- 数値にカスタムの単位を付与する
-
測定単位を使用するアプローチ
type kg type m let fiveKilos = 5.0<kg> let fiveMeters = 5.0<m> fiveKilos = fiveMeters // コンパイルエラー let listOfWeights = [ fiveKilos fiveMeters // コンパイルエラー ] // 誤ってポンドの値で初期化できないようにする type KilogramQuantity = KilogramQuantity of decimal<kg>
- 数値の場合は、測定単位を使用するアプローチがある
型システムによる不変条件の強制
- 型システムによる不変条件の強制
- UnitQuantity(ユニット数)は常に1から1000の間でなければならないなどの制約
- 1つの注文には少なくとも1つの明細行が必要
-
リストが空ではないことを保証する
type NonEmptyList<'a> = { First: 'a Rest: 'a list } type Order = { ... OrderLines : NonEmptyList<OrderLine> ... }
-
ビジネスルールを型システムで表現する
- ビジネスルールを型システムで表現する
- ルールが維持されているかどうかを実行時チェックやコードコメントではなく、コンパイラーによってチェックできるようにする
- 例: 検証リンクをクリックした検証済みメールアドレスと、その他のメールアドレスを分けて処理したい
- 検証メールは未検証のメールアドレスにのみ送信すべき
- パスワードリセットのメールは、検証済みのメールアドレスにのみ送信すべき
- よくある実装とGOODの例
- 不正な状態は表現できないようにすること
- 余計なユニットテストを書く必要がなくなる
- コンパイル時にエラーとなるから
- また、ドメインをよりよく文書化できる
- 例: メールアドレスを表現する
-
よくある実装とBAD・GOODの例
// ❌ BAD: フラグによるよくある実装 type CustomerEmail = { EmailAddress : EmailAddress IsVerified : bool // 何のために設定され、どう解除されるかが不明 } // ✅ GOOD: ドメインエキスパートが話していることをモデリングする // 1. パターンを作成する: ドメインエキスパートが「検証済み」と「未検証]のメールについて話しているなら、 // それらを別々のものとしてモデリングする type CustomerEmail = | Unverified of EmailAddress | Verified of VerifiedEmailAddress // 通常のメールアドレスと異なる型 // 2. 不正な状態は表現できないようにする: VerifiedEmailAddressにプライベートコンストラクタを定義することで、 // 検証サービスだけがこの型を作成できるようにする。 // これで、VerifiedEmailAddressを取得するには、検証サービスから受け取るしかなくなる // パスワードリセットメッセージを送信するワークフローでは、通常のメールアドレスではなく、検証済みのメールアドレスを受け取る必要があることを明示できる type SendPasswordResetEmail = VerifiedEmailAddress -> ...
-
- 例: 「顧客は、電子メールまたは郵便のアドレスを持っている必要があります」という要件がある場合
-
「顧客は、電子メールまたは郵便のアドレスを持っている必要があります」という要件がある場合
// 「顧客は、電子メールまたは郵便のアドレスを持っている必要があります」という要件がある場合 // ❌ BAD: EmailとAddressの両方が必須となってしまっている type Contact = { Name : Name Email : EmailContactInfo Address : PostalContactInfo } // ❌ BAD: EmailとAddressの両方が欠けている可能性がある type Contact = { Name : Name Email : EmailContactInfo option Address : PostalContactInfo option } // ✅ GOOD: ルールをよく見て、それに合わせてモデリングする // メールアドレスのみ・住所のみ・メールアドレスと住所の両方の3つの可能性を持つことができる type BothContactMethods = { Email : EmailContactInfo Address : PostalContactInfo } type ContactInfo = | EmailOnly of EmailContactInfo | AddressOnly of PostalContactInfo | EmailAndAddress of BothContactMethods type Contact = { Name : Name ContactInfo : ContactInfo }
-
- 不正な状態は表現できないようにすること
- 不正な状態を私たちのドメインで表現できないようにすること
-
検証済みと型を分ける
type AddressValidationService = UnvalidatedAddress -> ValidatedAddress option type UnvalidatedOrder = { ... ShippingAddress : UnvalidatedAddress ... } type ValidatedOrder = { ... ShippingAddress : ValidatedAddress ... }
-
整合性
- 整合性
- 整合性とは
- 技術的な用語ではなくビジネス用語である
- 簡単な整合性の例
- 注文の合計金額は、個々の行の合計と同じでなければならない
- 注文が確定すると、それに対応する請求書が作成されなければならない
- 注文時にクーポンコードを利用した場合、使用済みとしてマークしなければならない
- 複雑な整合性の例
- 製品の価格が変更された場合、未発送の注文は直ちに新しい価格に更新されるべきか?
- 顧客の住所が変更された場合、未発送の注文は直ちに新しい住所に更新されるべきか?
- 簡単な整合性の例
- 整合性はビジネスニーズによる
- 整合性は永続化の原始性に関連している
- 注文が部分的に永続化されたとき、ある部分だけ保存に失敗すると、後でその注文を読み込んだときに整合性のない注文を読み込むこととなる
- 技術的な用語ではなくビジネス用語である
- 1つの集約内での整合性
- 例: 注文の合計金額は、個々の行の合計と同じでなければならない
- 整合性を確保する最も簡単な方法は、都度生データから計算すること
- メモリ内やSQLクエリを使用して明細行から合計を算出する
-
都度生データから計算して整合性を保つ
/// 3つのパラメーターを渡す: /// * トップレベルの注文 /// * 変更したい注文明細行のID /// * 新しい価格 let changeOrderLinePrice order orderLineId newPrice = // orderLineIdを使用してorder.OrderLinesからorderLineを検索する let orderLine = order.OrderLines |> findOrderLine orderLineId // 新しい価格を持つ、新しいバージョンの注文明細行を作成する let newOrderLine = {orderLine with Price = newPrice} // 古い明細行を新しい明細行で置き換えた、新しい明細行リストを作成する let newOrderLines = order.OrderLines |> replaceOrderLine orderLineId newOrderLine // 新しい請求額の合計を計算する let newAmountToBill = newOrderLines |> List.sumBy (fun line -> line.Price) // 新しい明細行リストを持つ、新しいバージョンの注文を作成する let newOrder = { order with OrderLines = newOrderLines AmountToBill = newAmountToBill } // 新しい注文を返す newOrder
- 異なるコンテキスト間の整合性
- 例: 注文が確定すると、それに対応する請求書が作成されなければならない
- 請求書の発行は受注ドメインではなく、請求ドメインの一部
- 境界づけられたコンテキストは分離しておかなければならないので、他のドメインに手を伸ばしてオブジェクトを操作してはいけない
- 課金コンテキストのパプリックAPIを使うようにする方法
-
金コンテキストのパプリックAPIを使うようにする方法
Ask billing context to create invoice If successfully created: create order in order-taking context
- どちらかのアップデートが失敗した場合に対処する必要がある
- 1つのステージが終えるのを待ってから次のステージに行くなど、すべてを同期させておく必要はない
- メッセージを使って非同期に調整すればいい
- まれにエラーとなるが、すべてを同期させておくためのコストよりもはるかに少ない
- 請求ドメインにメッセージ(またはイベント)を送信する
- メッセージが失われて、請求書が作成されなかった場合にどうするか
-
- 何もしない
- 顧客は無料で商品を手に入れてしまう可能性があるが、コストがとても小さい場合は適切な解決方法であることもある
-
- メッセージが失われたことを検出して再送信する
- 2つのデータセットを比較して、一致しない場合はエラーを修正するという照合プロセスが行う
-
- 前のアクションを元に戻す補償アクションを作成する
- 注文をキャンセルして顧客に商品を送り返すように求める
- 補償アクションは、注文の間違いを修正したり返金したりするために使用される
-
- 整合性が求められる場合、2か3の選択肢を実施する必要がある
- この整合性はすぐには有効にならないため、結果整合性と呼ばれる
-
- 同じコンテキストの集約間の整合性
- 2つの集約がお互いに整合性を保つ必要がある場合どうするか
- 一般的には、1つのトランザクションにつき1つの集約のみを更新するというガイドラインが有効
- 一緒に更新するかどうかは場合による
-
2つの集約の整合性を保つ例
// 同じトランザクションで2つの異なる集約を更新している Start transaction Add X Amount to accountA Remove X amount from accountB Commit transaction // トランザクション自体がDDDのエンティティだと考えて、識別子を持たせる type MoneyTransfer = { Id : MoneyTransferId ToAccount : AccountId FromAccount : AccountId Amount : Money }
- 理由がないなら集約の再利用にとらわれる必要はなく、たった1つのユースケースでしか使わない集約でも新しく定義するのがいい
- 同一データに作用する複数の集約
- 例: Account集約とMoneyTransfer集約があり、どちらもアカウントの残高に作用し、どちらも残高がマイナスにならないようにする必要がある
- 型を使って制約をモデル化していれば、複数の集約間で制約を共有できる
- NonNegativeMoeny(非負の金額)型でモデル化できる
- あるいは、検証関数の共有もできる
- 整合性とは
まとめ
- ドメイン内のデータの信頼性を確保する方法を学んだ
- 型システムで完全性ルールを適用してユニットテストを減らし、より自己文書化されたコードにする
- 単純な型: スマートコンストラクタを使う
- 複雑な型: 不正な状態は表現できないようにする
- 境界づけられたコンテキスト間: 結果整合性を考慮して設計する
- 型システムで完全性ルールを適用してユニットテストを減らし、より自己文書化されたコードにする
7. パイプラインによるワークフローのモデリング
- パイプラインによるワークフローのモデリング
- 注文確定ワークフローに適用してみる
- ドメインエキスパートが読めるものを作ることを目標とする
- 注文確定ワークフロー
-
注文確定ワークフローのステップ
workflow "Place Order" = input: UnvalidatedOrder output: (on success): OrderAcknowledgmentSent AND OrderPlaced (to send to shipping) AND BillableOrderPlaced (to send to billing) output (on error): ValidationError // ステップ1 do ValidateOrder If order is invalid then: return with ValidationError // ステップ2 do PriceOrder // ステップ3 do AcknowledgeOrder // ステップ4 create and return the events
-
- ビジネスプロセスを表すパイプラインを作成し、それをさらに小さなパイプの集合体として構築する
- それぞれ小さなパイプでは1つのものだけを変換し、それを接着して大きなパイプラインを作っていく
- 変換指向プログラミングと呼ばれる
- パイプラインの各ステップはステートレスで副作用がないように設計する
- 各ステップを独立してテストし、理解できるようになる
- あとは実装して組み立てるだけとなる
- それぞれ小さなパイプでは1つのものだけを変換し、それを接着して大きなパイプラインを作っていく
- 注文確定ワークフローに適用してみる
ワークフローの入力
- ワークフローの入力
- ワークフローへの入力は常にドメインオブジェクトでなければならない
- DTOからデシリアライズされている必要がある
-
未検証の注文(入力値)
type UnvalidatedOrder = { OrderId : string CustomerInfo : UnvalidatedCustomerInfo ShippingAddress : UnvalidatedAddress ... }
- 入力としてのコマンド
- ワークフローはそれを開始するコマンドと関連している
- ある意味、ワークフローの本当の入力はコマンドである
- 注文確定ワークフローでは、PlaceOrder(注文を確定する)と呼ぶ
- ワークフローがリクエストを処理するために必要なすべての内容が含まれている必要がある
-
入力としてのコマンド
type PlaceOrder = { OrderForm : UnvalidatedOrder Timestamp : DateTime UserId : string // etc }
- ワークフローはそれを開始するコマンドと関連している
- ジェネリクスによる共通構造の共有
- UserId(ユーザーID)やTimestamp(タイムスタンプ)は、他のすべてのコマンドと共通のフィールドとなる
- OOPでは継承で解決するが、関数型の世界ではジェネリクスを使用して共有する
-
type Command<'data> = { Data : 'data Timestamp : DateTime UserId : string // etc } type PlaceOrder = Command<UnvalidatedOrder>
- UserId(ユーザーID)やTimestamp(タイムスタンプ)は、他のすべてのコマンドと共通のフィールドとなる
- 複数のコマンドを1つの型にまとめる
- 境界づけられたコンテキストのすべてのコマンドが同じ入力チャネル(メッセージキューなど)で送信されることもある
- その場合、それらをシリアライズできる1つのデータ構造に統一する方法が必要となる
- すべてのコマンドを含む選択型を作成することで解決する
-
すべてのコマンドを含む選択型を作成する
type OrderTakingCommand = | Place of PlaceOrder | Change of ChangeOrderLinePrice | Cancel of CancelOrder
- 選択型はDTOにマッピングされて、入力チャネル上でシリアライズ・デシリアライズされる
- 境界づけられたコンテキストのすべてのコマンドが同じ入力チャネル(メッセージキューなど)で送信されることもある
- ワークフローへの入力は常にドメインオブジェクトでなければならない
状態の集合による注文のモデリング
- 状態の集合による注文のモデリング
- 状態遷移のモデリング方法
- Order(注文)は単なる静的な書類ではなく、実際にはさまざまな状態に遷移している
- これらの状態をどうやってモデル化するか?
-
❌フラグで表現した素朴な単一レコードのモデル
type Order = { OrderId : OrderId ... IsValidated : bool // 検証時に設定される IsPriced : bool // 価格決定時に設定される AmountToBill : decimal option // 価格計算時に設定される }
- 多くの問題がある
- 処理するときに多くの条件付きコードを書く必要がある
- いくつかの状態には他の状態では必要とされないデータがあり、1つのレコードでまとめると設計が複雑になってしまう
- 例: AmountToBill(請求額の合計)は「価格設定済み」状態でのみ必要となり、他の状態には依存しないためフィールドを省略可能にする必要がある
- どのフィールドがどのフラグに対応するのかが明確でなくなる
- 例: IsPricedが設定されている場合はAmountToBillも必須だが、このルールを強制する設計が存在しないため、コメントを見ながらデータの整合性を保つように注意しなければならない
- ドメインのモデル化のよりよい方法は、注文の各状態に対して新しい型を作成すること
- 新しい状態の型を作成する
-
✅新しい状態の型を作成する
data ValidatedOrder = ValidatedCustomerInfo AND ValidatedShippingAddress AND ValidatedBillingAddress AND list of ValidatedOrderLine // ValidatedOrderに対応する型定義 type ValidatedOrder = { OrderId : OrderId CustomerInfo : CustomerInfo ShippingAddress : Address BillingAddress : Address OrderLines : ValidatedOrderLine list } // PricedOrderに対応する型定義 type PricedOrder = { OrderId : ... CustomerInfo : CustomerInfo ShippingAddress : Address BillingAddress : Address // 検証済みの注文明細行とは異なる OrderLines : PricedOrderLine list AmountToBill : BillingAmount } // 取りうる状態をすべて作成したら、そこから1つ選択するトップレベルの型を作成する type Order = | Unvalidated of UnvalidatedOrder | Validated of ValidatedOrder | Priced of PricedOrder // etc
-
- 要件の変更に伴う新しい状態型の追加
- 状態ごとに新しい型を使用すると、既存のコードを壊すことなく新しい状態を追加できる
- 返金サポートが必要になったときに、RefundedOrder(返金済みの注文)状態を新たに追加できる
- 他の状態は独立して定義されているので、それらを使用するコードは変更の影響を受けない
- 状態遷移のモデリング方法
ステートマシン
- ステートマシン
- ステートマシンとは
- ステートマシンの例
- なぜステートマシンを使うのか
- それぞれの状態において、受けつける処理を変えられる
- ショッピングカートの例では、アクティブなカートだけが支払い可能とすることができる
- 状態ごとに別の型とすることで、その要件を関数のシグネチャに直接エンコードでき、コンパイラーを使用してそのビジネスルールを遵守させることができる
- (現実では「これってまだ〇〇してませんよね?やりますか」などの会話となるため、ドメインエキスパートも理解できる)
- すべての状態が明示的に文書化される
- 暗黙の了解でありながら文書化されていない重要な状態がある、というのはよく起こりがち
- ショッピングカートでは、空のカートがアクティブなカートと違った動作をするが、それがコード上で明示的に文書化されていることはまれ
- 起こりうる状況をすべて考慮に入れて設計するように強く促される
- 設計誤りの一般的な原因は、特定のエッジケースの考慮漏れが多い
- ステートマシンではエッジケースも含めて考慮しなければならないので、強制的に考えることになる
- すでに認証されているメールアドレスを認証しようとするとどうなるか
- 空のショッピングカートから商品を取り出そうとするとどうなるか
- すでに発送済み状態になっている荷物を発送しようとするとどうなるか
- 状態の観点から設計を考えることでドメインロジックをさらに明確にできる
- それぞれの状態において、受けつける処理を変えられる
- F#でシンプルなステートマシンを実装する方法
- 各状態に独自の型を持たせ、その状態に関連するデータを格納する
-
ショッピングカートのステートマシンの例
type Item = ... type ActiveCartData = { UnpaidItems: Item list } type PaidCartData = { PaidItems: Item list; Payment: float } type ShoppingCart = | EmptyCart // データ無し | ActiveCart of ActiveCartData | PaidCart of PaidCartData // コマンドハンドラーは、ステートマシン全体を受け取り、更新された選択型を返す関数 let addItem cart item = match cart with | EmptyCart -> // 指定されたアイテムで、新しいアクティブなカートを作成する ActiveCart {UnpaidItems=[item]} | ActiveCart {UnpaidItems=existingItems} -> // 指定されたアイテムと追加済みのアイテムで、新しいアクティブなカートを作成する ActiveCart {UnpaidItems = item :: existingItems} | PaidCart _ -> cart // カートに入っている商品の支払いをする let makePayment cart payment = match cart with | EmptyCart -> // 無視する cart | ActiveCart {UnpaidItems=existingItems} -> // 指定された支払いで、新しい支払い済みのカートを作成する PaidCart {PaidItems = existingItems; Payment=payment} | PaidCart _ -> // 無視する cart
- ステートマシンとは
02へ続く
Discussion