🎃
『関数型ドメインモデリング(Domain Modeling Made Functional)』を読んだ 03
11. シリアライズ
- なぜシリアライズが必要か
- 本書ではワークフローを入力と出力をもつ関数として設計している
- 入力をコマンド、出力をイベントとして扱っている
- ではコマンドはどこから来るのか、イベントはどこへ行くのか?
- 答えは、境界づけられたコンテキストの外のインフラ
- 具体的にはWebリクエストや、メッセージキューとなる
- 外のインフラはドメインを理解していない
- インフラが理解できるような形(JSON・XMLなど)に変換する必要がある
- この場合にデータベースなどの外部サービスが利用される
- 連携の際に重要なのは、ドメインモデルの型をシリアライズ・デシリアライズ容易なものに変換可能にすること
- ドメインオブジェクトを中間型との間で変換する方法を見ていく
- 本書ではワークフローを入力と出力をもつ関数として設計している
永続化とシリアライズ
- 永続化とシリアライズ
- 永続化とは
- 作成元のプロセスよりも長く続く状態を意味する
- シリアライズとは、ドメイン固有の表現からバイナリ・JSON・XMLなどの永続化が容易な表現に変換するプロセスのこと
- ワークフローのコードが実行された後でも、ビジネスの他の部分がそのデータを使用したい場合
- 出力を何らかの形で残しておく(永続化)
- 必ずしもデータベース保存ではなく、ファイルやキューに保存も可能
- 永続化されたデータの寿命については仮定すべきではない
- キューは数秒、データウェアハウスなら数十年保たれることもある
- 永続化とは
シリアライズのための設計
- シリアライズのための設計
- ドメイン型は複雑で、選択や制約がネストされている
- シリアライザーがうまく扱うのに適していない
- ドメインオブジェクトをシリアライズ専用の型(DTO)に変換して、ドメイン型の代わりにDTOをシリアライズする
- デシリアライズは同じプロセスを逆に行う
- DTOへのデシリアライズは、基礎データが破損していない限りは、常に成功すべき
- 有効性の検証などはDTOからドメイン型への変換プロセスで行うようにする
- 境界づけられたコンテキストの内側のほうが、エラー処理のコントロールがしやすいため
- ドメイン型は複雑で、選択や制約がネストされている
シリアライズコードとワークフローの連携
- シリアライズコードとワークフローの連携
- シリアライズプロセスは、ワークフローのパイプラインにもう1つのコンポーネントを追加するだけ
-
シリアライズを組み込んだ例
type MyInputType = ... type MyOutputType = ... type Workflow = MyInputType -> MyOutputType type JsonString = string type MyInputDto = ... type DeserializeInputDto = JsonString -> MyInputDto type InputDtoToDomain = MyInputDto -> MyInputType type MyOutputDto = ... type OutputDtoFromDomain = MyOutputType -> MyOutputDto type SerializeOutputDto = MyOutputDto -> JsonString let workflowWithSerialization jsonString = jsonString |> deserializeInputDto // JSONからDTOに変換する |> inputDtoToDomain // DTOからドメインオブジェクトに変換する |> workflow // ドメインのコアワークフロー |> outputDtoFromDomain // ドメインオブジェクトからDTOに変換する |> serializationOutputDto // DTOからJSONに変換する // 最終出力は別のJsonString型になる
-
- 境界づけられたコンテキスト間の契約としてのDTO
- ワークフローが受け取るコマンドは、他の境界づけられたコンテキストの出力によってトリガーされる
- 出力としてのイベントは他の入力となる
- シリアライズのフォーマットはライブラリ任せではなく自分で書く方がいい
- 関数型に親和性がないときも多い
- もし使うならラップして使うとよい
- ワークフローが受け取るコマンドは、他の境界づけられたコンテキストの出力によってトリガーされる
- シリアライズの全体例
- formDomain関数とtoDomain関数をペアにする
-
シリアライズの全体例
// ドメイン駆動による型 module Domain /// NULLではなく、最大50文字であるという制約 type String50 = String50 of string /// 1900年1月1日以降、今日の日付以前という制約 type BirthDate = BirthDate of DateTime /// ドメイン型 type Person = { First: String50 Last: String50 BirthDate : BirthDate } /// DTOに関連するすべての型と関数をグループ化するモジュール module Dto = type Person = { First: string Last: string BirthDate: DateTime } module Person = let fromDomain (person:Domain.Person): Dto.Person = // ドメインオブジェクトからプリミティブ値を取得する let first = person.First |> String50.value let last = person.Last |> String50.value let birthDate = person.BirthDate |> BirthDate.value // コンポーネントを結合してDTOを作成する { First = first; Last = last; BirthDate = birthDate } let toDomain (dto: Dto.Person): Result<Domain.Person, string> = // エラーフローを処理するために、resultコンピュテーション式を使用する result { // DTOから単純型を取得して検証し、成功または失敗として扱う let! first = dto.First |> String50.create "First" let! last = dto.Last |> String50.create "Last" let! birthDate = dto.BirthDate |> BirthDate.create return { First = first Last = last BirthDate = birthDate } } let create fieldName str : Result<String50, string> = if String.IsNullOrEmpty(str) then Error (fieldName + " must be non-empty") elif str.Length > 50 then Error (fieldName + " must be less that 50 chars") else Ok (String50 str)
- JSONシリアライザーのラッピング
- ライブラリを使用するときは関数型に対応するようにラッピングをするとよい
-
JSONシリアライザーのラッピング例
module Json = open Newtonsoft.Json let serialize obj = JsonConvert.SerializeObject obj let deserialize<'a> str = try JsonConvert.DeserializeObject<'a> str |> Result.Ok with // すべての例外をキャッチし、Resultに変換する | ex -> Result.Error ex
- シリアライズパイプラインの全体
-
シリアライズパイプラインの使用例
/// PersonをJSON文字列にシリアライズする let jsonFromDomain (person:Domain.Person) = person |> Dto.Person.fromDomain |> Json.serialize // テストしてみる // テスト入力 let person : Domain.Person = { First = String50 "Alex" Last = String50 "Adams" BirthDate = BirthDate (DateTime(1980,1,1)) } // シリアライズパイプラインを使用する jsonFromDomain person // 出力: // "{\"First\":\"Alex\",\"Last\":\"Adams\",\"BirthDate\":\"1980-01-01T00:00:00\"}"
- デシリアライズパイプラインはResultを含む可能性があるため少し複雑になる
- 共通の選択型に変換しresult式を使用してエラーを隠蔽する
- デシリアライズ時のエラーを例外スローにするかどうか?
- 予期される状況として処理したいか、パイプライン全体をクラッシュさせるパニックとして処理したいかによる
- さらにこれは、APIがどの程度公開されているか、呼び出し側をどの程度信頼しているか、エラーについて呼び出し側にどの程度情報を提供したいのかによる
-
デシリアライズパイプラインの使用例
type DtoError = | ValidationError of string | DeserializationException of exn /// JSON文字列をPersonにデシリアライズする let jsonToDomain jsonString: Result<Domain.Person, DtoError> = result { let! deserializedValue = jsonString |> Json.deserialize |> Result.mapError DeserializationException let! domainValue = deserializedValue |> Dto.Person.toDomain |> Result.mapError ValidationError return domainValue } // テストしてみる(エラーなしの場合) // テスト用のJSON文字列 let jsonPerson = """{ "First": "Alex", "Last": "Adams", "BirthDate": "1980-01-01T00:00:00" }""" // デシリアライズパイプラインを使用する jsonToDomain jsonPerson |> printfn "%A" // 出力: // Ok {First = String50 "Alex"; // Last = String50 "Adams"; // BirthDate = BirthDate 01/01/1980 00:00:00;} // テストしてみる(エラーの場合) // テスト用のJSON文字列 let jsonPersonWithErrors = """{ "First": "", "Last": "Adams", "BirthDate": "1776-01-01T00:00:00" }""" // デシリアライズパイプラインを使用する jsonToDomain jsonPersonWithErrors |> printfn "%A" // 出力: // Error (ValidationError [ // "First must be non-empty";])
-
- 他のシリアライザーとの連携
- シリアライズ型をドメイン型から分離しておく利点の1つ
- 複雑な属性で汚染されることがなくなるから
-
他のシリアライザーとの連携の例
module Dto = [<DataContract>] type Person = { [<field: DataMember>] First: string [<field: DataMember>] Last: string [<field: DataMember>] BirthDate: DateTime }
- シリアライズ型をドメイン型から分離しておく利点の1つ
- 複数バージョンのシリアライズ型を扱う
- 設計が進化していくと、フィールドの追加や削除、名前の変更などのドメイン型の変更が必要になることがある
- DTO型も変更する必要が出てくる
- これは複数バージョンのDTO型をサポートする必要があるということ
- 手法について
- シリアライズプロセスは、ワークフローのパイプラインにもう1つのコンポーネントを追加するだけ
ドメイン型をDTOに変換する方法
- ドメイン型をDTOに変換する方法
- DTOはプリミティブ型を含む単純な構造であることが必要
- ガイドライン
- 単一ケース共用体(単純型)
type ProductCode = ProductCode of string
- 対応するDTOはシンプルなstringとなる
- オプション型
- Noneのケースをnullに置き換えられる
- intなどの値型は
Nullable<int>
のようなnull許容型を使う
- レコード型
-
レコード型のDTOの例
/// ドメイン型 type OrderLineId = OrderLineId of int type OrderLineQty = OrderLineQty of int type OrderLine = { OrderLineId : OrderLineId ProductCode : ProductCode Quantity : OrderLineQty option Description : string option } /// 対応するDTO型 type OrderLineDto = { OrderLineId : int ProductCode : string Quantity : Nullable<int> Description : string }
-
- コレクション
-
コレクションのDTO型の例
/// ドメイン型 type Order = { ... Lines : OrderLine list } /// 対応するDTO型 type OrderDto = { ... Lines : OrderLineDto[] } /// ドメイン型 type Price = Price of decimal type PriceLookup = Map<ProductCode, Price> /// マップを表現するDTO型 type PriceLookupPair = { Key : string Value : decimal } type PriceLookupDto = { KVPairs : PriceLookupPair [] } /// マップを表現する別のDTO型(デシリアライズの際に結合する) type PriceLookupDto = { Keys : string [] Values : decimal [] }
-
- 列挙体として扱える判別共用体
-
列挙体として扱える判別共用体のDTOの例
/// ドメイン型 type Color = | Red | Green | Blue /// 対応するDTO型 type ColorDto = | Red = 1 | Green = 2 | Blue = 3 let toDomain dto : Result<Color,_> = match dto with | ColorDto.Red -> Ok Color.Red | ColorDto.Green -> Ok Color.Green | ColorDto.Blue -> Ok Color.Blue | _ -> Error (sprintf "Color %0 is not one of Red, Green, Blue" dto)
-
- タプル
-
タプルのDTOの例
/// タプルの構成要素 type Suit = Heart | Spade | Diamond | Club type Rank = Ace | Two | Queen | KilogramQuantity // 簡潔にするため不完全 /// タプル type Card = Suit * Rank /// 対応するDTO型 type SuitDto = Heart = 1 | Spade = 2 | Diamond = 3 | Club = 4 type RankDto = Ace = 1 | Two = 2 | Queen = 3 | King = 13 type CardDto = { Suit : SuitDto Rank : RankDto }
-
- 選択型
-
選択型のDTO型の例
/// ドメイン型 type Name = { First : String50 Last : String50 } type Example = | A | B of int | C of string list | D of Name /// 対応するDTO型 type NameDto = { First : string Last : string } type ExampleDto = { Tag : string // "A", "B", "C", "D"のいずれか1つ BData : Nullable<int> // ケースBのデータ CData : string[] // ケースCのデータ DData : NameDto // ケースDのデータ } // シリアライズ let nameDtoFromDomain (name:Name): NameDto = let first = name.First |> String50.value let last = name.Last |> String50.value { First = first; Last = last } let fromDomain (domainObj:Example): ExampleDto = let nullBData = Nullable() let nullCData = null let nullDData = Unchecked.defaultof<NameDto> match domainObj with | A -> {Tag="A"; BData=nullBData, CData=nullCData; DData=nullDData} | B i -> let bdata = Nullable i {Tag="B"; BData=bdata; CData=nullCData; DData=nullDData} | C strList -> {Tag="C"; BData=nullBData; CData=strList; DData=nullDData} | D name -> let ddata = name |> nameDtoFromDomain {Tag="D"; BData=nullBData; CData=nullCData; DData=ddata} // デシリアライズ let nameDtoToDomain (nameDto:NameDto): Result<Name,string> = result { let! first = nameDto.First |> String50.create let! last = nameDto.Last |> String50.create return {First=first; Last=last} } let toDomain dto : Result<Example,string> = match dto.Tag with | "A" -> Ok a | "B" -> if dto.BData.HasValue then dto.BData.Value |> B |> Ok else Error "B data not expected to be null" | "C" -> match dto.CData with | null -> Error "C data not expected to be null" | _ -> dto.CData |> Array.toList |> C |> Ok | "D" -> match box dto.DData with | null -> Error "D data not expected to be null" | _ -> dto.DData |> nameDtoToDomain // これがResultを返す |> Result.map D // ここでResultをマップする | _ -> // 残りのケース let msg = sprintf "Tag '%s' not recognized" dto.Tag Error msg
-
- レコード型や選択型のマップを使ったシリアライズ
- 複合型(レコードや判別共用体)の別のシリアライズ方法として、すべてをキーバリューマップとしてシリアライズする方法がある
- .NETだと
IDictionary<string,obj>
とする - JSONフォーマットでの作業に特に適する
- DTO構造に暗黙の契約が存在しないため、非常に疎結合なやり取りにできる
- 逆に契約がまったくないためむずかい場合もある
- .NETだと
-
Nameレコードをキーバリューマップとしてシリアライズ/デシリアライズする例
let nameDtoFromDomain (name:Name): IDictionary<string, obj> = let first = name.First |> String50.value :> obj let last = name.Last |> String50.value :> obj [ ("First", first) ("Last", last) ] |> dict let fromDomain (domainObj:Example): IDictionary<string,obj> = match domainObj with | A -> [ ("A", null) ] |> dict | B i -> [ ("B", bdata) ] |> dict | C strList -> [ ("C", cdata) ] |> dict | D name -> [ ("D", ddata) ] |> dict let getValue key (dict: IDictionary<string, obj>): Result<'a, string> = match dict.TryGetValue key with | (true, value) -> // キーが見つかった try // 'a型にダウンキャストしてOkを返す (value :?> 'a) |> Ok with | :? InvalidCastException -> // キャストに失敗 let typeName = typeof<'a>.Name let msg = sprintf "Value could not be cast to %s" typeName Error msg | (false, _) -> // キーが見つからない let msg = sprintf "Key %s' not found" key Error msg let nameDtoToDomain (nameDto: IDictionary<string, obj>): Result<Name, string> = result { let! firstStr = nameDto |> getValue "First" let! first = firstStr |> String50.create let! lastStr = nameDto |> getValue "Last" let! last = lastStr |> String50.create return {First=first; Last=last} } let toDomain (dto: IDictionary<string, obj>): Result<Example, string> = if dto.ContainsKey "A" then Ok A // 追加のデータは不要 elif dto.ContainsKey "B" then result { let! bData = dto |> getValue "B" // 失敗する可能性がある return B bData } elif dto.ContainsKey "C" then result { let! cData = dto |> getValue "C" // 失敗する可能性がある return cData |> Array.toList |> C } elif dto.ContainsKey "D" then result { let! Data = dto |> getValue "D" // 失敗する可能性がある let! name = dData |> nameDtoToDomain // ここでも失敗する可能性がある return name |> D } else // 残りのケース let msg = sprintf "No union case recognized" Error msg
- 複合型(レコードや判別共用体)の別のシリアライズ方法として、すべてをキーバリューマップとしてシリアライズする方法がある
- ジェネリクス
- シリアライズライブラリがジェネリクスをサポートしていれば、ジェネリクスを使用したDTOも作成できる
-
ジェネリクスを使用したDTO
type ResultDot<'OkData, 'ErrorData when 'OkData : null and 'ErrorData: null> = { IsError : bool // "Tag"フィールドを置き換える OkData : 'OkData ErrorData : 'ErrorData } type PlaceOrderResultDto = { IsError : bool OkData : PlaceOrderEventDto[] ErrorData : PlaceOrderErrorDto }
- 単一ケース共用体(単純型)
まとめ
- 境界づけられたコンテキストとクリーンなドメインから離れて、インフラというカオスに足を踏み入れた
- 仲介役としてDTOを設計した
- シリアライズは外界とのやり取りの1つではあるが、唯一ではない
12. 永続化
- 本書では、ドメインモデルを設計する際に「永続化非依存」を考慮している
- データの保存や他のサービスとのやり取りで設計がゆがめられることはない
- 現実では、ドメイン駆動型データモデルの永続化関連の課題に対処する必要がある
- コマンドとクエリの分離などの原則
- NoSQLデータベースやSQLデータベースでの永続化方法
- ドメイン駆動設計においての永続化のガイドライン
- 永続化を端に追いやる
- コマンド(更新)とクエリ(読み取り)を分離する
- 境界づけられたコンテキストは、それぞれ独自のデータストアを持つ必要がある
永続化を端に追いやる
- 永続化を端に追いやる
- 理想的にはすべての関数を純粋にしたいが、外部のデータを扱う関数は純粋にはなり得ない
- そのためワークフローを設計する際には、ワークフロー内でI/Oや永続化関連のロジックを一切用いないことを目指す
- 2つの部分となる
- ビジネスロジックを含むドメイン中心的な部分
- I/O関連コードを含む端の部分
- 2つの部分となる
- 請求書に支払いをさせるワークフローの例
- 実装例
- データベースから請求書をロードする
- 支払いを適用する
- 請求書が支払い完了された場合、データベースで支払い完了とマークし、InvoicePaid(支払い完了)イベントを投稿する
- 請求書が部分的に支払われた場合、データベースで部分的に支払われたものとしてマークし、イベントを投稿しない
- コード例
- ドメインロジックとI/Oが混在するワークフローだとテストが難しくなる
-
ドメインロジックとI/Oが混在するワークフロー
// ドメインロジックとI/Oが混在するワークフロー let payInvoice invoiceId payment = // DBから読み込む let invoice = loadInvoiceFromDatabase(invoiceId) // 支払いを適用する invoice.ApplyPayment(payment) // 異なる結果を処理する if invoice.IsFullyPaid then markAsFullyPaidInDb(invoiceId) postInvoicePaidEvent(invoiceId) else markAsPartiallyPaidInDb(invoiceId)
-
- 純粋なビジネスロジックをapplyPaymen関数に切り出すようにする
- パラメーターのスタブを用意するだけで簡単にテストできるようになる
- I/Oを使用する合成関数は、アプリケーションのトップレベルに配置する
-
純粋なビジネスロジックをapplyPaymen関数に切り出した例
type InvoicePaymentResult = | FullyPaid | PartiallyPaid of ... // ドメインワークフロー: 純粋関数 let applyPayment unpaidInvoice payment: InvoicePaymentResult = // 支払いを適用する let updatedInvoice = unpaidInvoice |> applyPayment payment // 異なる結果を処理する if isFullyPaid updatedInvoice then FullyPaid else PartiallyPaid updatedInvoice type PayInvoiceCommand = InvoiceId: ... Payment: ... // 境界づけられたコンテキストの端にあるコマンドハンドラー let payInvoice loadUnpaidInvoiceFromDatabase // 依存関係 markAsFullyPaidInDb // 依存関係 postInvoicePaidEvent // 依存関係 updateInvoiceInDb // 依存関係 payInvoiceCommand = // DBから読み込む let invoiceId = payInvoiceCommand.InvoiceId let unpaidInvoice = loadUnpaidInvoiceFromDatabase invoiceId // I/O // 純粋なドメインでの呼び出し let payment = payInvoiceCommand.Payment // 純粋 let paymentResult = applyPayment unpaidInvoice payment // 純粋 // 結果を処理する match paymentResult with | FullyPaid -> markAsFullyPaidInDb invoiceId // I/O postInvoicePaidEvent invoiceId // I/O | PartiallyPaid updatedInvoice -> updateInvoiceInDb updatedInvoice // I/O
- ドメインロジックとI/Oが混在するワークフローだとテストが難しくなる
- 実装例
- クエリに基づく意思決定
- 純粋なコードの途中で、データベースからの読み込みに基づいて判定しないといけない場合もある
- 例: 支払い後、債務の合計額を計算して、それが大きすぎる場合は顧客に警告メッセージを送信したいという要件
-
拡張したパイプラインのステップ
--- I/O --- Load Invoice from DB --- 純粋 --- Do payment logic --- I/O --- Pattern match on output choice type: If "FullyPaid" -> Mark invoice as paid in DB If "PartiallyPaid" -> Save updated invoice in DB --- 純粋 --- Add the amounts up and decide if amount is too large --- I/O --- Pattern match on output choice type: If "OverdueWarningNeeded" -> Send message to customer If "NoActionNeeded" -> do nothing
- ロジックが混在しすぎる場合は、ワークフローをより短いミニワークフローに分割するとよい
- 個々のワークフローは小さくてシンプルなサンドイッチ状態にできる
- 純粋なコードの途中で、データベースからの読み込みに基づいて判定しないといけない場合もある
- リポジトリパターンはどこにある?
- 関数型のアプローチでは、すべてを関数としてモデル化して永続化を端に追いやるので、リポジトリパターンは必要なくなる
コマンドとクエリの分離(CQS)
- コマンドとクエリの分離(CQS)
- ストレージシステムもある種の不変なオブジェクトと考えてみる
- ストレージシステム内のデータを変更するたびに、それ自体が新しいバージョンに変換されると考える
- レコードの挿入を関数型でモデル化してみる
- insert関数は、挿入するデータとデータストアの元の状態という2つのパラメーターを持つと考える
- 挿入が完了した後の関数の出力は、データが追加されたデータストアの新しいバージョンとなる
-
データストアの操作をモデル化する
type InsertData = DataStoreState -> Data -> NewDataStoreState type ReadData = DataStoreState -> Query -> Data type UpdateData = DataStoreState -> Data -> NewDataStoreState type DeleteData = DataStoreState -> Key -> NewDataStoreState
- コマンドとクエリの分離の設計原則では、操作には2種類ある
- データベースの状態を変更するもの(コマンド):
insert, update, delete
- データベースの状態を変更せず、データを返すもの(クエリ):
read, query
- データベースの状態を変更するもの(コマンド):
- 「質問したときに答えを変えるべきではない」ということ
- 関数型プログラミングに適用したCQS原則
- データを返す関数は副作用を持つべきではない
- 副作用(状態の更新)を持つ関数はデータを返すべきではない
- ユニット型(void)を返す関数であるべき
- 先ほどのデータストア操作モデルを改良する
-
データストア操作モデルの改良
// DataStoreStateをデータストアへのなんらかのハンドルに置き換える // 実際はデータストアは可変なのでNewDataStoreStateをUnit型で置き換える type InsertData = DbConnection -> Data -> Unit type ReadData = DbConnection -> Query -> Data type UpdateData = DbConnection -> Data -> Unit type DeleteData = DbConnection -> Key -> Unit // 部分適用でDbConnectionを隠す type InsertData = Data -> Unit type ReadData = Query -> Data type UpdateData = Data -> Unit type DeleteData = Key -> Unit // I/Oやエラー対応など何らかの副作用を含める必要がある type DbError = ... type DbResult<'a> = AsyncResult<'a, DbError> type InsertData = Data -> DbResult<Unit> type ReadData = Query -> DbResult<Data> type UpdateData = Data -> DbResult<Unit> type DeleteData = Key -> DbResult<Unit>
-
- 関数型プログラミングに適用したCQS原則
- コマンドクエリ責務分離(CQRS)
- 読み取りと書き込みに同じオブジェクトを再利用したくなることはよくある
- Customerレコードに対してデータベースに保存したり読み込んだりする
-
読み取りと書き込みに同じオブジェクトを再利用する例
type SaveCustomer = Customer -> DbResult<Unit> type LoadCustomer = CustomerId -> DbResult<Customer>
-
- Customerレコードに対してデータベースに保存したり読み込んだりする
- 同じ型のものを読み書きに再利用するデメリット
- クエリが返すデータは、データを書き込むときに必要なものとは異なることが多い
- クエリではデータや計算値を返すが、これは書き込み時には使用しない
- 新しいレコードを作成する場合は、生成されたIDやバージョンなどのフィールドは使用されないが、クエリでは返される
- クエリとコマンドは独立して進化する傾向がある
- 結合すると独立して進化できなくなる
- 同じデータに対して3つ4つの異なるクエリが必要になるが、更新コマンドは1つだけということもある
- クエリの型とコマンドの型が同じでなければならない場合に不便となる
- (単一責任原則を守れない)
- 結合すると独立して進化できなくなる
- 複数のエンティティを一度に返す必要がある場合に不便となる
- パフォーマンス上の理由から、注文と注文に関する顧客データを一度にまとめて取得する場合など
- クエリが返すデータは、データを書き込むときに必要なものとは異なることが多い
- 1つのデータ型で複数用途に対応させるよりも、用途それぞれにデータ型を作った方がよい
- クエリ型とコマンド型を分離すると、それぞれ別々のモジュールに分けられる
- 疎結合となるため独立して進化できる
- これがコマンドクエリ責務分離(CQRS: Command-Query Responsibility Segregation)と呼ばれるようになった
- CQRSでのデータアクセス関数
-
CQRSでのデータアクセス関数
type SaveCustomer = WriteModel.Customer -> DbResult<Unit> type LoadCustomer = CustomerId -> DbResult<ReadModel.Customer>
-
- 読み取りと書き込みに同じオブジェクトを再利用したくなることはよくある
- CQRSとデータベース分離
- 論理なデータストアを2種類準備する
- 書き込み: インデックス無し、トランザクションありなど
- 読み取り: 非正規化、多量のインデックスなど
- 物理的なRDBMSでは、書き込みモデルは単純にテーブルにして、読み取りモデルはそのテーブルに対する定義済みのビューにできる
- 論理なデータストアを2種類準備する
- イベントソーシング
- CQRSはイベントソーシングと関連することが多い
- イベントソーシングのアプローチとは
- 現在の状態は単一のオブジェクトとして永続化されるのではない
- 状態に変化があるたびに、その変化を表すイベント(InvoicePaidなど)が永続化される
- バージョン管理システムのように、以前の状態と新しい状態の間の各変更点がキャプチャされる
- イベントソーシングの利点
- 「会計士は消しゴムを使わない」が実現できる
- ストレージシステムもある種の不変なオブジェクトと考えてみる
境界づけられたコンテキストはデータストレージを所有しなければならない
- 境界づけられたコンテキストはデータストレージを所有しなければならない
- どういうことか?
- 境界づけられたコンテキストは、独自のデータストレージと関連スキーマを所有する
- 他のコンテキストと調整の必要なく、いつでもそれらを変更できるようになる
- 他のシステムからは境界づけられたコンテキストが所有するデータに直接アクセスはできない
- クライアントはパブリックAPIかデータストアの何らかのコピーを使用するべき
- コードベースが完全に独立していても、システムAがシステムBのデータストアにアクセスする場合、実際には2つのシステムは結合していることになる
- これらの目標は、境界づけられたコンテキストが切り離され、独立して進化できるようにするためにある
- 分離するための実装はさまざまある
- 極端な方法としては、物理的に異なるデータベースやデータストアを持つ例
- もう1つの極端な例は、データストアは1つにして名前空間などで論理的に分離する例
- 境界づけられたコンテキストは、独自のデータストレージと関連スキーマを所有する
- 複数ドメインのデータを扱う
- レポーティングやBIは別ドメインとして扱う
- コピーして利用する
- 元となるシステムとレポーティングシステムを独立して進化させることができる
- BIで取得する方法
- 他のシステムから発行されるイベントをサブスクライブする方法
- BIコンテキストが単なる別のドメインであり、設計において特別扱いしなくてよいという利点がある
- 元となるシステムからBIシステムにデータをコピーする方法
- 最初の実装は容易になるが保守の手間は増える
- 元となるシステムのスキーマ変更に追随しなければならない
- 他のシステムから発行されるイベントをサブスクライブする方法
- レポーティングやBIは別ドメインとして扱う
- どういうことか?
ドキュメントデータベースを扱う
- ドキュメントデータベースを扱う
- ドキュメントデータベースの永続化
- ドメインオブジェクトをDTOに変換し、さらにJSON文字列に変換してストレージシステムのAPIを介して保存したり読み込んだりする
-
PersonDtoオブジェクトをAzure blobストレージに保存する例
open Microsoft.WindowsAzure open Microsoft.WindowsAzure.Storage open Microsoft.WindowsAzure.Storage.Blob let connString = "... Azure connection string ..." let storageAccount = CloudStorageAccount.Parse(connString) let blobClient = storageAccount.CreateCloudBlobClient() let container = blobClient.GetContainerReference("Person"); container.CreateIfNotExists() type PersonDto = { PersonId : int ... } let savePersonDtoToBlob personDto = let blobId = sprintf "Person%i" personDto.PersonId let blob = container.GetBlockBlobReference(blobId) let json = Json.serialize personDto blob.UploadText(json)
- ドキュメントデータベースの永続化
リレーショナルデータベースを扱う
- リレーショナルデータベースを扱う
- リレーショナルデータベースと関数型プログラミング
- 関数型プログラミングのデータモデルはRDBとの親和性は高い
- 関数型モデルではデータと動作が混同されないため、レコードの保存や取得がより直接的になるため
- RDBのテーブルは関数型モデルのレコードのコレクションにうまく対応している
- データベースにおける集合指向の操作(SELECT, WHERE)は、関数型言語におけるリスト指向の操作(map, filter)と似ている
-
関数型モデルと対応するテーブルの例
type Customer = { CustomerId : CustomerId Name : String50 BirthDate : BirthDate option } // 対応するテーブル CREATE TABLE Customer { CustomerId int NOT NULL, Name NVARCHAR(50) NOT NULL, Birthdate DATETIME NULL, CONSTRAINT PK_Customer PRIMARY KEY (CustomerId) }
- リレーショナルデータベースの難しい点
- リレーショナルデータベースはstringやintなどのプリミティブな値しか保存できない
- ProductCode(製品コード)やOrderId(注文ID)などのドメイン型をアンラップする必要がある
- リレーショナルテーブルは選択型にうまく対応できない
- リレーショナルデータベースはstringやintなどのプリミティブな値しか保存できない
- 関数型プログラミングのデータモデルはRDBとの親和性は高い
- 選択型をテーブルにマッピングする
- では選択型をRDBでどうモデル化すればいいのか?
- 選択型を1レベルの継承階層と考える
- すべてのケースを同じテーブルに格納する
- どのケースが使われているかを示すフラグが必要
- 一部のケースだけに使われるカラムではNULLを許容する必要がある
- 各ケースをそれぞれのテーブルに格納する
- 複雑さが増すが、データベースにより適切な制約(子テーブルにNOT NULLカラムなど)を設定できる
- ケースに関連するデータが非常に大きく、共通点が少ない場合はこちらのアプローチがよい
- すべてのケースを同じテーブルに格納する
- 選択型を1レベルの継承階層と考える
- Contact型という選択型を保存する例
-
Contact型という選択型を保存する例
type Contact = { ContactId : ContactId Info : ContactInfo } and ContactInfo = | Email of EmailAddress | Phone of PhoneNumber and EmailAddress = EmailAddress of string and PhoneNumber = PhoneNumber of string and ContactId = ContactId of int // 1つのテーブルにすべてのデータを格納するアプローチ CREATE TABLE ContactInfo ( -- 共通のデータ ContactId int NOT NULL, -- ケースフラグ IsEmail bit NOT NULL, IsPhone bit NOT NULL, -- Emailケースのデータ EmailAddress NVARCHAR(100) -- NULLを許容 -- Phoneケースのデータ PhoneNumber NVARCHAR(25), -- NULLを許容 CONSTRAINT PK_ContactInfo PRIMARY KEY (ContactId) ) // 各ケースをそれぞれのテーブルに格納するアプローチ -- 主テーブル CREATE TABLE ContactInfo ( -- 共通のデータ ContactId int NOT NULL, -- ケースフラグ IsEmail bit NOT NULL, IsPhone bit NOT NULL, CONSTRAINT PK_ContactInfo PRIMARY KEY (ContactId) ) -- Emailケースのテーブル CREATE TABLE ContactEmail ( ContactId int NOT NULL, -- ケースに固有のデータ EmailAddress NVARCHAR(100) NOT NUll, CONSTRAINT PK_ContactEmail PRIMARY KEY (ContactId) ) -- Phoneケースのテーブル CREATE TABLE ContactPhone ( ContactId int NOT NULL, -- ケースに固有のデータ PhoneNumber NVARCHAR(25) NOT NULL, CONSTRAINT PK_ContactPhone PRIMARY KEY (ContactId) )
-
- では選択型をRDBでどうモデル化すればいいのか?
- ネストした型をテーブルにマッピングする
- 型が他の型を含んでいる場合はどうするか
- 内部型が独自のアイデンティティを持つDDDエンティティの場合、別テーブルに格納すべき
- 内部型がDDDの値オブジェクトの場合、親データにインラインで保存すべき
- Order(注文)型がOrderLine(注文明細行)型の値リストを含んでいる例
-
Order(注文)型がOrderLine(注文明細行)型の値リストを含んでいる例
// OrderとOrderLineの関係 CREATE TABLE Order ( OrderId int NOT NULL, -- 残りのカラム ) CREATE TABLE OrderLine ( OrderLineId int NOT NULL, OrderId int NOT NULL, -- 残りのカラム ) // Order型がAddress値オブジェクトを含む場合 CREATE TABLE Order ( OrderId int NOT NULL, -- 配送先住所の値オブジェクトをインライン化 ShippingAddress1 varchar(50) ShippingAddress2 varchar(50) ShippingAddress3 varchar(50) -- 以降は省略 -- 請求先住所の値オブジェクトをインライン化 BillingAddress1 varchar(50) BillingAddress2 varchar(50) BillingAddressCity varchar(50) -- 以降は省略 -- 残りのカラム )
-
- 型が他の型を含んでいる場合はどうするか
- リレーショナルデータベースからの読み取り
- F#はORMを使わずに生のSQLコマンドを扱う
-
FSharp.Data.SqlClient
型プロバイダを使用する
-
- CustomerId(顧客ID)を使って、1つのCustomer(顧客)を読み取る例
-
CustomerId(顧客ID)を使って、1つのCustomer(顧客)を読み取る例
open FSharp.Data [<Literal>] let CompileTimeConnectionString = @"Data Source=(localdb)\MsSqlLocalDb; Initial Catalog=DomainModelingExample" type ReadOneCustomer = SqlCommandProvider<""" SELECT CustomerId, Name, Birthdate FROM Customer WHERE CustomerId = @customerId """, CompileTimeConnectionString> // データベースを検証が必要な信頼できないデータソースとして扱う場合 let toDomain (dbRecord:REadOneCustomer.Record) : Result<Customer,_> = result { let! customerId = dbRecord.CustomerId |> CustomerId.create let! name = dbRecord.Name |> String50.create "Name" let! birthDate = dbRecord.Birthdate |> Result.bindOption Birthdate.create let customer = { CustomerId = customerId Name = name Birthdate = birthdate } return customer } let bindOption f xOpt = match xOpt with | Some x -> f x |> Result.map Some | None -> Ok None // データベースに不正なデータが含まれることは絶対にないと仮定し、 // もしデータが不正な場合は例外をスローする場合 let toDomain (dbRecord:ReadOneCustomer.Record) : Customer = let customerId = dbRecord.CustomerId |> CustomerId.create |> panicOnError "CustomerId" let name = dbRecord.Name |> String50.create "Name" |> panicOnError "Name" let birthdate = dbRecord.Birthdate |> Result.bindOption Birthdate.create |> panicOnError "Birthdate" // Customerを返す {CustomerId = customerId; Name = name; Birthdate = birthdate} exception DatabaseError of string let panicOnError columnName result = match result with | Ok x -> X | Error err -> let msg = sprintf "%s: %A" columnName err raise (DatabaseError msg) /// readOneCustomer関数 type DbReadError = | InvalidRecord of string | MissingRecord of string let readOneCustomer (productionConnection:SqlConnection) (CustomerId customerId) = // 先に定義した型をインスタンス化してコマンドを生成する use cmd = new ReadOneCustomer(productionConnection) // コマンドを実行する let records = cmd.Execute(customerId = customerId) |> Seq.toList // 想定されるケースを処理する match records with // 見つからなかった | [] -> let msg = sprintf "Not found. CustomerId=%A" customerId Error (MissingRecord msg) // 1つだけ見つかった | [dbCustomer] -> dbCustomer |> toDomain |> Result.mapError InvalidRecord // 1つよりも多く見つかったら? | _ -> let msg = sprintf "Multiple records found for CustomerId=%A" customerId raise (DatabaseError msg) /// ジェネリックなヘルパー関数を作成し、すべてをパラメーターとして渡すようにして整理する let convertSingleDbRecord tableName idValue records toDomain = match records with // 見つからなかった | [] -> let msg = sprintf "Not found. Table=%s Id=%A" tableName idValue Error msg // Resultを返す // 1つだけ見つかった | [dbRecord] -> dbRecord |> toDomain |> Ok // Resultを返す // 1つよりも多く見つかったら? | _ -> let msg = sprintf "Multiple records found. Table=%s Id=%A" tableName idValue raise (DatabaseError msg) // ジェネリックなヘルパー関数を使用してreadOneCustomerを再実装する let readOneCustomer = (productionConnection:SqlConnection) (CustomerId customerId) = use cmd = new ReadOneCustomer(productionConnection) let tableName = "Customer" let records = cmd.Execute(customerId = customerId) |> Seq.toList convertSingleDbRecord tableName customerId records toDomain
-
- F#はORMを使わずに生のSQLコマンドを扱う
- リレーショナルデータベースから選択型を読み取る
- 選択型は少し複雑になる
- ContactInfoレコードを1つのテーブルに格納し、ContactIdを使用して1件のContactInfoを読み取る例
-
ContactIdを使用して1件のContactInfoを読み取る例
type ReadOneContact = SqlCommandProvider<""" SELECT ContactId, IsEmail, IsPhone, EmailAddress, PhoneNumber FROM ContactInfo WHERE ContactId = @contactId """, CompileTimeConnectionString> // IsEmailをチェックしてContactInfoのどのケースを作成するか決定し、 // 子のResultを使って書くケースのデータを組み立てる let toDomain (dbRecord:ReadOneContact.Record) : Result<Contact,_> = result { let! contactId = dbRecord.ContactId |> ContactId.create let! contactInfo = if dbRecord.IsEmail then result { // NULLであってはならないプリミティブ文字列を取得する let! emailAddressString = dbRecord.EmailAddress |> Result.ofOption "Email Expected to be non null" // メールアドレスの単純型を作成する let! emailAddress = emailAddressString |> EmailAddress.create // ContactInfoのEmailケースに持ち上げる return (Email emailAddress) } else result { // NULLであってはならないプリミティブ文字列を取得する let! phoneNumberString = dbRecord.PhoneNumber |> Result.ofOption "PhoneNumber expected to be non null" // 電話番号の単純型を作成する let! phoneNumber = phoneNumberString |> PhoneNumber.create // ContactInfoのPhoneケースに持ち上げる return (Phone phoneNumber) } let contact = { ContactId = contactId Info = contactInfo } return contact }
-
-
toDomain
関数を作成する必要がある- マッピングを自動で行ってくれるツールを使えばいいので、この関数は無駄ではないか?という疑問
- 答えはノー
- ORMではメールアドレスの検証や注文数の検証、ネストした選択型の処理などができない
- ドメイン性を確保するためには必要となる
- 確かに面倒ではあるが、機械的なプロセスなので難しくはない
- マッピングを自動で行ってくれるツールを使えばいいので、この関数は無駄ではないか?という疑問
- リレーショナルデータベースへの書き込み
- 読み込みと同じように、ドメインオブジェクトをDTOに変換し、挿入または更新コマンドを実行する
- SLQ型プロバイダにテーブル構造を表す可変の方を生成させ、その型のフィールドを設定する
-
リレーショナルデータベースへの書き込みの例
// 型プロバイダにテーブル構造を表す可変の型を生成させる type Db = SqlProgrammabilityProvider<CompileTimeConnectionString> let writeContact (productionConnection:SqlConnection) (contact:Contact) = // ドメインオブジェクトからプリミティブデータを抽出する let contactId = contact.ContactId |> ContactId.value let isEmail,isPhone,emailAddressOpt,phoneNumberOpt = match contact.Info with | Email emailAddress -> let emailAddressString = emailAddress |> EmailAddress.value true,false,Some emailAddressString,None | Phone phoneNumber -> let phoneNumberString = phoneNumber |> PhoneNumber.value false,true,None,Some phoneNumberString // 新しい行を作成する let contactInfoTable = new Db.dbo.Tables.ContactInfo() let newRow = contactInfoTable.NewRow() newRow.ContactId <- contactId newRow.IsEmail <- isEmail newRow.IsPhone <- isPhone newRow.EmailAddress <- emailAddressOpt newRow.PhoneNumber <- phoneNumberOpt // テーブルに追加する contactInfoTable.Rows.Add newRow // データベースに変更を加える let recordsAffected = contactInfoTable.Update(productionConnection) recordsAffected /// 手書きのSQLコマンドを使用してデータベースに書き込む方法 type InsertContact = SqlCommandProvider<""" INSERT INTO ContactInfo VALUES (@ContactId,@IsEmail,@IsPhone,@EmailAddress,@PhoneNumber) """, CompileTimeConnectionString> let writeContact (productionConnection:SqlConnection) (contact:Contact) = // ドメインオブジェクトからプリミティブデータを抽出する let contactId = contact.ContactId |> ContactId.value let isEmail,isPhone,emailAddress,phoneNumber = match contact.Info with | Email emailAddress -> let emailAddressString = emailAddress |> EmailAddress.value true,false,emailAddressString,null | Phone phoneNumber -> let phoneNumberString = phoneNumber |> PhoneNumber.value false,true, null phoneNumberString // データベースに書き込む use cmd = new InsertContact(productionConnection) cmd.Execute(contactId,isEmail,isPhone,emailAddress,phoneNumber)
- 読み込みと同じように、ドメインオブジェクトをDTOに変換し、挿入または更新コマンドを実行する
- トランザクション
- まとめて全か無かで保存しなければないないものがたくさんある
- データストアの中にはAPIの一部としてトランザクションをサポートしているものがある
-
トランザクションの例
let connection = new SqlConnection() let transaction = connection.BeginTransaction() // 1つのトランザクション中にデータベースを2回別々に呼び出す markAsFullyPaid connection invoiceId markPaymentCompleted connection paymentId // 完了 transaction.Commit() /// データベースによっては、すべてが1つの接続で実行される場合に限りトランザクションをサポートするものもある let connection = new SqlConnection() // サービスへの呼び出しを一度に行う markAsFullyPaidAndPaymentCompleted connection paymentId invoiceId /// サービスをまたぐトランザクション /// データベースの更新をロールバックするための補償トランザクションの例 // 最初の呼び出し markAsFullyPaid connection invoiceId // 2回目の呼び出し let result = markPaymentCompleted connection paymentId // 2回目の呼び出しが失敗した場合、補償トランザクションを行う match result with | Error err -> // エラーを補償する unmarkAsFullyPaid connection invoiceId | Ok _ -> ...
- まとめて全か無かで保存しなければないないものがたくさんある
- リレーショナルデータベースと関数型プログラミング
まとめ
- 本章では永続化の大まか原則を見てみた
- クエリとコマンドの分離
- I/Oを端に追いやる
- 境界づけられたコンテキストの完全な実装を設計し、作成するために必要な道具はすべてそろった
- 軍隊の格言「敵に遭遇すれば計画は必ず変わる」
13. 設計を進化させ、きれいに保つ
- 設計を進化させ、きれいに保つ
- ドメインモデルは最初こそきれいでエレガントだが、要件が変わるにつれてモデルが混沌としていく
- 大きな泥団子になることもある
- ドメイン駆動設計は、一度だけの静的なプロセスではない
- 開発者、ドメインエキスパート、その他の関係者が継続的に協力することを意味する
- 要件変更があれば、必ず最初にドメインモデルを見直すことから始める
- 要件変更を確認する
- ドメインモデルへの解釈にどういう影響があるかをまず掘り下げる
- 実装の変更はその後となる
- 型を多用した設計なので、モデルに変更を加えてもコードを誤って壊すことはないと自信が持てる
- ドメインモデルは最初こそきれいでエレガントだが、要件が変わるにつれてモデルが混沌としていく
変更点1: 送料の追加
- 送料の追加
- 最初の要件変更
- たとえば会社がカリフォルニアを拠点としていて、近隣州への発送は5ドル、遠隔州への発送は10ドル、他の国への発送は20ドル追加したいとする
- ❌ よくない条件分岐ロジックの例(維持が難しい)
-
❌ よくない条件分岐ロジックの例
/// 注文の送料を計算する let calculateShippingCost validatedOrder = let shippingAddress = validatedOrder.ShippingAddress if shippingAddress.Country = "US" then // 米国内の発送 match shippingAddress.State with | "CA" | "OR" | "AZ" | "NV" -> 5.0 // 近隣州 | _ -> 10.0 // 遠隔州 else // 他の国への発送 20.0
-
- アクティブパターンによるビジネスロジックの簡素化
- ドメインにおける分類の考え方を、実際の価格設定ロジックから分離すること
- 条件分岐ロジックを名前つき選択肢のセットに変えて、パターンマッチ可能にできる
- (アクティブパターン: 条件分岐のロジックを関数として分離し、その判定結果を使って次の処理を行う方法)
- 条件分岐を分離することで再利用可能にする
- 関心事が分離されるため、テストがやりやすくなる
- 「入力データ -> 判定ロジック -> 結果 -> 次の処理」とデータの流れが明確になり、副作用がなくなる
- ケース名がドキュメントとしても機能するようになる
- ✅ アクティブパターンのアプローチの例
-
✅ アクティブパターンのアプローチの例
let (|UsLocalState|UsRemoteState|International|) address = if address.Country = "US" then match address.State with | "CA" | "OR" | "AZ" | "NV" -> UsLocalState | _ -> UsRemoteState else International let calculateShippingCost validatedOrder = match validatedOrder.ShippingAddress with | UsLocalState -> 5.0 | UsRemoteState -> 10.0 | International -> 20.0
-
- ドメインにおける分類の考え方を、実際の価格設定ロジックから分離すること
- ワークフローに新しいステージを作る
- 合成を最大限に利用する
-
// 型を定義する type AddShippingInfoToOrder = PricedOrder -> PricedOrderWithShippingInfo type ShippingMethod = | PostalService | Fedex24 | Fedex48 | Ups48 type ShippingInfo = { ShippingMethod : ShippingMethod ShippingCost : Price } // 新しい注文型を作成する目的 // ステージの順番を間違えることがなくなる // PricedOrderのフィールドとしてShippingInfoを追加した場合、初期化が難しくなる type PricedOrderWithShippingMethod = { ShippingInfo : ShippingInfo PricedOrder : PricedOrder } // 実装する let addShippingInfoToOrder calculateShippingCost : AddShippingInfoToOrder = fun pricedOrder -> // 発送情報を作成する let shippingInfo = { ShippingMethod = ... ShippingCost = calculateShippingCost pricedOrder } // 注文に発送情報を追加する { OrderId = pricedOrder.OrderId ... ShippingInfo = shippingInfo } // パイプラインステージのローカルパージョンの設定 // 部分適用で依存関係を組み込む let validateOrder unvalidatedOrder = ... let priceOrder validatedOrder = ... let addShippingInfo = addShippingInfoToOrder calculateShippingCost // 新しい1引数関数からパイプラインを合成する unvalidatedOrder |> validateOrder |> priceOrder |> addShippingInfo ...
- パイプラインにステージを追加するその他の理由
- このようなコンポーネントの追加・削除をするやり方は、どんな種類の機能追加にも適している
- ステージが他のステージから隔離されていて、要求された型に適合していれば、追加や削除を安全に行える
- ステージ追加のアプローチで実現できること
- ロギング、性能メトリクス、監査などのステージの追加
- 権限の認可チェックのステージを追加して、権限が足りない場合に残りをスキップして失敗パスに送ることができる
- 設定や入力からのコンテキストに基づいて、コンポジションルートでステージを動的に追加・削除できる
- 最初の要件変更
変更点2: VIP顧客への対応の追加
- VIP顧客への対応の追加
- ワークフローの入力全体に影響を与える変更をどうするか
- VIP顧客へは送料無料、夜間配達への無料アップグレードなど
- 避けるべきこと
- 安易に注文に「送料無料」のフラグを追加する
- 入力に対してビジネスルールが動作するという状態が壊れてしまう
- 安易に注文に「送料無料」のフラグを追加する
- VIPステータスをどのようにモデル化するか
-
VIPステータスのモデル化の例
// CustomerInfoにフラグを持たせる方法 type CustomerInfo = { ... IsVip : bool ... } // 顧客の状態としてモデル化する方法 type CustomerStatus = | Normal of CustomerInfo | Vip of CustomerInfo type Order = { ... CustomerStatus : CustomerStatus ... } // ✅ 最善の方法は折衷案となる(直交する顧客状態があるかもしれないため) type VipStatus = | Normal | Vip type CustomerInfo = { ... VipStatus : VipStatus ... } // 他の種類のステータスが必要になった場合 type LoyaltyCardId = ... type LoyaltyCardStatus = | None | LoyaltyCard of LoyaltyCardId type CustomeInfo = { ... VipStatus : VipStatus LoyaltyCardStatus : LoyaltyCardStatus }
-
- ワークフローに新しい入力を追加する
- どこでVIPだとわかるのかを特定する
- ワークフローへの入力であるUnvalidatedCustomerInfo(未検証の顧客情報)から
- VipSatusフィールドを新設する
-
VipSatusフィールドを新設する
type VipStatus = ... type CustomerInfo = { ... VipStatus : VipStatus } // ワークフローへの入力であるUnvalidatedCustomerInfo(未検証の顧客情報)から // VipStatusを取得する // よって、UnvalidatedCustomerInfoとDTOの両方にフィールドを追加する必要がある module Domain = type UnvalidatedCustomerInfo = { ... VipStatus : string } module Dto = type CustomerInfo = { ... VipStatus : string } // ValidatedCustomerInfo(検証済みの顧客情報)を構築する let validateCustomerInfo unvalidatedCustomerInfo = result { ... // 追加したフィールド let! vipStatus = VipStatus.create unvalidatedCustomerInfo.VipStatus let customerInfo : CustomerInfo = { ... VipStatus = vipStatus } return customerInfo }
- どこでVIPだとわかるのかを特定する
- 送料無料ルールをワークフローに追加する
- パイプラインの
AddShippingInfo
の後に追加する -
送料無料ルールの型定義
type FreeVipShipping = PricedOrderWithShippingMethod -> PricedOrderWithShippingMethod
- パイプラインの
- ワークフローの入力全体に影響を与える変更をどうするか
変更点3: プロモーションコードのサポートの追加
- プロモーションコードのサポートの追加
- 営業チームが販促キャンペーンを行いたいと考え、プロモーションコードを提供して割引価格で購入できるようにしたい
- 新しい要件
- 注文時に任意のプロモーションコードを入力できる
- 入力されたコードが存在する場合、特定の製品が別の(安い)価格で提供される
- 注文にはプロモーション割引が適用されたことが表示される
- 一見簡単そうな変更もあるが、ドメイン全体に驚くほど強力な波紋を投げかけるものもある
- 新しい要件
- ドメインモデルにプロモーションコードを追加する
- ドメインモデルを更新する
-
PromotionCodeをドメインモデルに追加する
// ドメイン内の他の文字列と混ざらないように型を使用する type PromotionCode = PromotionCode of string type ValidatedOrder = { ... PromotionCode : PromotionCode option } // UnvalidatedOrderとDTOにも対応するフィールドを追加する type OrderDto = { ... PromotionCode : string } type UnvalidatedOrder = { ... PromotionCode : string }
-
- ドメインモデルを更新する
- 価格計算路ロジックの変更
- プロモーションコードがある場合は専用の価格計算をする必要があり、ない場合は通常の計算をする必要がある
- プロモーションのあるなしに対応した計算関数を返すファクトリー関数を作成する
-
ファクトリー関数を作成する
// 既にある価格計算モデル type GetProductPrice = ProductCode -> Price // プロモーションのあるなしに対応した計算関数を返すファクトリー関数を作成する // ・プロモーションコードが存在する場合、それに紐づいた価格を返すGetProductPrice関数を提供する // ・プロモーションコードが存在しない場合、元のGetProductPrice関数を提供する type GetPricingFunction = PricingMethod -> GetProductPrice type PricingMethod = | Standard | Promotion of PromotionCode type ValidatedOrder = { ... PricingMethod : PricingMethod } // ファクトリー関数を価格設定のステップに注入する type PriceOrder = GetPricingFunction // 新しい依存関係 -> ValidatedOrder // 入力 -> PricedOrder // 出力
- プロモーションコードがある場合は専用の価格計算をする必要があり、ない場合は通常の計算をする必要がある
- GetPricingFunctionの実装
-
GetPricingFunctionの実装
let GetPricingFunction (StandardPrices:GetStandardPriceTable) (promotionPrices:GetPromotionPriceTable) : GetPricingFunction = // 元の価格設定関数 let getStandardPrice : GetProductPrice = // 標準価格をキャッシュする let standardPrices = standardPrices() // 辞書を参照する関数を返す fun productCode -> standardPrices.[productCode] // プロモーション価格設定関数 let getPromotionPrice promotionCode : GetProductPrice = // プロモーション価格をキャッシュする let promotionPrices = promoPrices promotionCode // 辞書を参照する関数を返す fun productCode -> match promotionPrices.TryGetValue productCode with // プロモーション価格が見つかった | true, price -> price // プロモーション価格が見つからなかった場合、標準価格を返す | false, _ -> getStandardPrice productCode // GetPricingFunctionに合致した関数を返す fun pricingMethod -> match pricingMethod with | Standard -> getStandardPrice | Promotion promotionCode -> getPromotionPrice promotionCode
-
- 注文明細行での割引の記録
- 「プロモーション割引が適用されたことが注文に表示される」という要件をどうするか
- 下流のシステムがそのプロモーションに関する情報を必要とするかどうかを知る必要がある
- プロモーションに関する情報を必要としないなら、もっとも簡単な方法は明細行のリストに、コメント行を新たに追加すること
- この場合、明細行の定義を変更するドメインモデルの変更をする必要がある
-
明細行の定義を変更する(ドメインモデルの変更)
// PricedOrderLineの定義を変更し、選択型にする type CommentLine = CommentLine of string type PricedOrderLine = | Product of PricedOrderProductLine | Comment of CommentLine
- 追加変更について
- PricedOrderLineを選択型に変更したので、PricedOrderProductLine(価格計算済みの注文明細行)も新たに必要になる
- ValidatedOrderLineとPricedOrderLineの設計を分けていたので、変更が可能となっている
- 両方に同じ型を使用していた場合、モデルをきれいに保つことはできない
- コメント行を追加する
- 流れ
- GetPricingFunctionファクトリーから価格設定関数を取得する
- 価格設定関数を使って価格を設定する
- プロモーションコードを使用した場合は、明細行のリストに特別なコメント行を追加する
-
コメント行を追加する
let toPricedOrderLine orderLine = ... let priceOrder : PriceOrder = fun getPricingFunction validatedOrder -> // getPricingFunctionファクトリーから価格設定関数を取得する let getProductPrice = getPricingFunction validatedOrder.PricingMethod // 各明細行に価格を設定する let productOrderLines = validatedOrder.OrderLines |> List.map (toPricedOrderLine getProductPrice) // 必要であれば特別なコメント行を追加する let orderLines = match validatedOrder.PricingMethod with | Standard -> // 変更なし productOrderLines | Promotion promotion -> let promoCode = promotion |> PromotionCode.value let commentLine = sprintf "Applied promotion %s" promoCode |> CommentLine.create |> Comment // PricedOrderLine型に持ち上げる List.append productOrderLines [commentLine] // 新しい注文を返す { ... OrderLines = orderLines }
- 流れ
- 「プロモーション割引が適用されたことが注文に表示される」という要件をどうするか
- より複雑な価格体系
- 価格設定がさらに複雑になる可能性もある
- プロモーションが複数になったり、バウチャーやロイヤリティスキームなどが増えたりする
- これらは別の境界づけられたコンテキストにする必要があるサインとなる
- 別の境界づけられたコンテキストにする必要があるサインの例
- 独特の語彙(「BOGOF: Buy One, Get One Free」などの専門用語がある)
- 価格を管理する特別なチームができる
- このコンテキストのみに特化したデータ(過去の購入履歴やクーポンの使用状況など)がある
- 自律的に行動できること
- 受注と密接に関連しながらも、論理的に分離された別の境界づけられたコンテキストにできる
- 価格設定がさらに複雑になる可能性もある
- 境界づけられたコンテキスト間の契約の進化
- 新しい明細行を導入したので、注文を正しく印刷するために発送システムがそれを知る必要がある
- OrderPlaced(注文が確定した)イベントも変更する必要がある
- 受注ドメインに新しい概念を追加するたびにイベントやDTOを変更する必要があるのか?
- 下流の「顧客」が上流の「供給者」に何を求めるか決める
- 「顧客/供給者」パターン
- 発送コンテキストが本当に必要としているものは何か考える
- 価格、送料、割引情報は必要ない
- 必要なのは、商品のリスト、それぞれの数量、発送先住所のみ
-
// 発送コンテキストが本当に必要としている情報 type ShippableOrderLine = { ProductCode : ProductCode Quantity : float } type ShippableOrderPlaced = { OrderId : OrderId ShippingAddress : Address ShipmentLines : ShippableOrderLine list } // PlaceOrderEvent出力を再設計する // ・AcknowledgementSent(確認を送った)をログに記録し、カスタマーサービスコンテキストに送信する // ・ShippableOrderPlaced(発送可能な注文が確定した)を発送コンテキストに送信する // ・BillableOrderPlaced(請求可能な注文が確定した)を請求コンテキストに送信する type PlaceOrderEvent = | ShippableOrderPlaced of ShippableOrderPlaced | BillableOrderPlaced of BillableOrderPlaced | AcknowledgmentSent of OrderAcknowledgmentSent
- 新しい明細行を導入したので、注文を正しく印刷するために発送システムがそれを知る必要がある
- 注文の印刷
- 発送部門は印刷できるものを必要としているだけで、実際の中身については関心がないことに気づくことが重要
- 注文確定コンテキストは発送部門にPDFやHTMLのドキュメントを提供して、それを印刷させるだけでも問題ない
- バイナリblobとしてShippableOrderPlaced型に含めることもできる
- PDFを共有ストレージにダンプして、発送コンテキストがOrderId(注文ID)を介してアクセスするようにすることもできる
- 注文確定コンテキストは発送部門にPDFやHTMLのドキュメントを提供して、それを印刷させるだけでも問題ない
- 発送部門は印刷できるものを必要としているだけで、実際の中身については関心がないことに気づくことが重要
- 営業チームが販促キャンペーンを行いたいと考え、プロモーションコードを提供して割引価格で購入できるようにしたい
変更点4: 営業時間制約の追加
- 営業時間制約の追加
- 新しいデータや動作の追加ではなく、ワークフローの使用方法に関する新しい制約を追加する
- 注文は営業時間内にのみ受けつけます
- このシステムを営業時間中にしか利用できないようにする
- アダプター関数を作成する方法
- 営業時間限定の関数を作成する
- 任意の関数を入力として受けつけ、営業時間外に呼び出された場合にエラーを発生させる
- ラッパーまたはプロキシとなる関数
- 元の関数が使われていた場所であればどこでも使用できる
-
/// 営業時間を判定する let isBusinessHour hour = hour >= 9 && hour <= 17 /// 変換関数 let businessHoursOnly getHour onError onSuccess = let hour = getHour() if isBusinessHour hour then onSuccess hour else onError() type PlaceOrderError = | Validation of ValidationError ... | OutsideBusinessHours // 追加 let placeOrder unvalidatedOrder = ... // 元のplaceOrder関数を入れ替えられるように、入出力が同じ関数を作成する let placeOrderInBusinessHours unvalidatedOrder = let onError() = Error OutsideBusinessHours let onSuccess() = placeOrder unvalidatedOrder let getHour() = DateTime.Now.hour businessHoursOnly getHour onError onSuccess
- 営業時間限定の関数を作成する
- 新しいデータや動作の追加ではなく、ワークフローの使用方法に関する新しい制約を追加する
さらなる要件変更への対応
- さらなる要件変更への対応
- VIPは、米国内での発送にのみ送料が無料になるかもしれない
- ワークフローのfreeVipShippingセグメントのコードを変更すればよいだけ
- 小さなセグメントを多く持つことで、複雑さを抑えられる
- 顧客は注文を複数の発送に分けることでできるようになるかもしれない
- ワークフロー内に新しいセグメントが必要となる
- ドメインモデリングの観点からは、単一の発送ではなく複数の発送のリストを発送コンテキストへ送ることだけが変更点となる
- 顧客は、発送されたかどうか、支払いがすべて終わっているかなど、注文の状態を見られるようになるかもしれない
- 「カスタマーサービス」のような新しいコンテキストを作成する
- 他のコンテキストからのイベントを受け取り、それに応じて状態を更新する
- VIPは、米国内での発送にのみ送料が無料になるかもしれない
まとめ
- 本章では、型駆動のドメインモデリングと、関数からワークフローを作成する合成のアプローチのメリットを理解した
- 型駆動の設計のメリット
- あるドメイン型に新しいフィールドを追加するとコンパイラエラーが発生するので、コードを修正することを強制できる
- VipStatusを追加する例
- 依存関係の1つを変更したときも、多くのコンパイラエラーが発生する
- プロモーションコードの追加の例
- GetProductPrice -> GetPricingFunctionに変更
- プロモーションコードの追加の例
- あるドメイン型に新しいフィールドを追加するとコンパイラエラーが発生するので、コードを修正することを強制できる
- 関数合成を使ってワークフローを構築するメリット
- 新しいセグメントを追加しても、他のセグメントはそのままにしておける
- 既存コードを変更しないので、バグ発生の可能性を減らすことができる
- 新しいセグメントを追加しても、他のセグメントはそのままにしておける
- インターフェースとしての関数型のメリット
- 既存のコードとのプラグインの互換性を維持しながら、関数全体を効率的に変更できる
- 営業時間の制約の追加の例
- 既存のコードとのプラグインの互換性を維持しながら、関数全体を効率的に変更できる
- 型駆動の設計のメリット
本書のまとめ
- 境界づけられたコンテキストという抽象概念への考察から、シリアライズフォーマットのような細部までカバーしてきた
- 取り上げられなかったトピック
- Webサービス
- セキュリティ
- 運用上の透明性
- もっとも重要なプラクティス
- 詳細な設計を始める前に、ドメインに関する深い共有理解を目指すべき
- イベントストーミングやユビキタス言語の活用が役にたつ
- 解決空間は独立して進化できるように、自律的で分離された境界づけられたコンテキストに分割するべき
- ワークフローは、明示的な入出力をもつ独立したパイプラインとして表現するべき
- コードを書く前に、ドメインの名詞と動詞を型ベースの表記法で把握しようとするべき
- 名詞は代数的型システムとなる
- 動詞は関数となる
- 重要な制約やビジネスルールは、可能な限り型システムで表現する
- 不正な状態は表現できないようにするため
- 純粋かつ完全であるように関数を設計していく
- 入力には明示的に文書化された出力があり、すべての動作は完全に予測可能になる
- 詳細な設計を始める前に、ドメインに関する深い共有理解を目指すべき
- 注文確定ワークフローでは最終的に型の集合が作成された
- 実装の過程での関数型プログラミングの重要な技術
- 小さな関数の組み合わせだけで、完全なワークフローを構築する
- 依存関係や後回しにしたい判断がある場合、関数をパラメーター化する
- 部分適用を使用して依存関係を関数に組み込み、関数をより簡単に合成できるようにするとともに、不要な実装の詳細を隠す
- 他の関数をさまざまな形に変換できる特殊な関数を作成する
- bindアダプターブロック
- エラーを返す関数を、2トラックの関数に変換する
- bindアダプターブロック
- 異なる型を共通の型に「持ち上げ」ることで、型が一致しない課題を解決する
- 取り上げられなかったトピック
Discussion