🎃

『関数型ドメインモデリング(Domain Modeling Made Functional)』を読んだ 03

2024/12/23に公開

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
            }
        
    • 複数バージョンのシリアライズ型を扱う
      • 設計が進化していくと、フィールドの追加や削除、名前の変更などのドメイン型の変更が必要になることがある
      • DTO型も変更する必要が出てくる
      • これは複数バージョンのDTO型をサポートする必要があるということ
      • 手法について

ドメイン型を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構造に暗黙の契約が存在しないため、非常に疎結合なやり取りにできる
          • 逆に契約がまったくないためむずかい場合もある
        • 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関連コードを含む端の部分
    • 請求書に支払いをさせるワークフローの例
      • 実装例
        • データベースから請求書をロードする
        • 支払いを適用する
        • 請求書が支払い完了された場合、データベースで支払い完了とマークし、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 ---
            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>
          
    • コマンドクエリ責務分離(CQRS)
      • 読み取りと書き込みに同じオブジェクトを再利用したくなることはよくある
        • Customerレコードに対してデータベースに保存したり読み込んだりする
          • 読み取りと書き込みに同じオブジェクトを再利用する例
              type SaveCustomer = Customer -> DbResult<Unit>
              type LoadCustomer = CustomerId -> DbResult<Customer>
            
      • 同じ型のものを読み書きに再利用するデメリット
        1. クエリが返すデータは、データを書き込むときに必要なものとは異なることが多い
          • クエリではデータや計算値を返すが、これは書き込み時には使用しない
          • 新しいレコードを作成する場合は、生成されたIDやバージョンなどのフィールドは使用されないが、クエリでは返される
        2. クエリとコマンドは独立して進化する傾向がある
          • 結合すると独立して進化できなくなる
            • 同じデータに対して3つ4つの異なるクエリが必要になるが、更新コマンドは1つだけということもある
            • クエリの型とコマンドの型が同じでなければならない場合に不便となる
            • (単一責任原則を守れない)
        3. 複数のエンティティを一度に返す必要がある場合に不便となる
          • パフォーマンス上の理由から、注文と注文に関する顧客データを一度にまとめて取得する場合など
      • 1つのデータ型で複数用途に対応させるよりも、用途それぞれにデータ型を作った方がよい
        • クエリ型とコマンド型を分離すると、それぞれ別々のモジュールに分けられる
        • 疎結合となるため独立して進化できる
        • これがコマンドクエリ責務分離(CQRS: Command-Query Responsibility Segregation)と呼ばれるようになった
      • CQRSでのデータアクセス関数
        • CQRSでのデータアクセス関数
            type SaveCustomer = WriteModel.Customer -> DbResult<Unit>
            type LoadCustomer = CustomerId -> DbResult<ReadModel.Customer>
          
    • CQRSとデータベース分離
      • 論理なデータストアを2種類準備する
        • 書き込み: インデックス無し、トランザクションありなど
        • 読み取り: 非正規化、多量のインデックスなど
      • 物理的なRDBMSでは、書き込みモデルは単純にテーブルにして、読み取りモデルはそのテーブルに対する定義済みのビューにできる
    • イベントソーシング
      • CQRSはイベントソーシングと関連することが多い
      • イベントソーシングのアプローチとは
        • 現在の状態は単一のオブジェクトとして永続化されるのではない
        • 状態に変化があるたびに、その変化を表すイベント(InvoicePaidなど)が永続化される
        • バージョン管理システムのように、以前の状態と新しい状態の間の各変更点がキャプチャされる
      • イベントソーシングの利点
        • 「会計士は消しゴムを使わない」が実現できる

境界づけられたコンテキストはデータストレージを所有しなければならない

  • 境界づけられたコンテキストはデータストレージを所有しなければならない
    • どういうことか?
      • 境界づけられたコンテキストは、独自のデータストレージと関連スキーマを所有する
        • 他のコンテキストと調整の必要なく、いつでもそれらを変更できるようになる
      • 他のシステムからは境界づけられたコンテキストが所有するデータに直接アクセスはできない
        • クライアントはパブリックAPIかデータストアの何らかのコピーを使用するべき
        • コードベースが完全に独立していても、システムAがシステムBのデータストアにアクセスする場合、実際には2つのシステムは結合していることになる
      • これらの目標は、境界づけられたコンテキストが切り離され、独立して進化できるようにするためにある
      • 分離するための実装はさまざまある
        • 極端な方法としては、物理的に異なるデータベースやデータストアを持つ例
        • もう1つの極端な例は、データストアは1つにして名前空間などで論理的に分離する例
    • 複数ドメインのデータを扱う
      • レポーティングや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)などのドメイン型をアンラップする必要がある
        • リレーショナルテーブルは選択型にうまく対応できない
    • 選択型をテーブルにマッピングする
      • では選択型をRDBでどうモデル化すればいいのか?
        • 選択型を1レベルの継承階層と考える
          • すべてのケースを同じテーブルに格納する
            • どのケースが使われているかを示すフラグが必要
            • 一部のケースだけに使われるカラムではNULLを許容する必要がある
          • 各ケースをそれぞれのテーブルに格納する
            • 複雑さが増すが、データベースにより適切な制約(子テーブルにNOT NULLカラムなど)を設定できる
            • ケースに関連するデータが非常に大きく、共通点が少ない場合はこちらのアプローチがよい
      • 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)
            )
          
    • ネストした型をテーブルにマッピングする
      • 型が他の型を含んでいる場合はどうするか
        • 内部型が独自のアイデンティティを持つ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
          
    • リレーショナルデータベースから選択型を読み取る
      • 選択型は少し複雑になる
      • 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)
        
    • トランザクション
      • まとめて全か無かで保存しなければないないものがたくさんある
        • データストアの中には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
            }
        
    • 送料無料ルールをワークフローに追加する
      • パイプラインの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)を介してアクセスするようにすることもできる

変更点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セグメントのコードを変更すればよいだけ
      • 小さなセグメントを多く持つことで、複雑さを抑えられる
    • 顧客は注文を複数の発送に分けることでできるようになるかもしれない
      • ワークフロー内に新しいセグメントが必要となる
      • ドメインモデリングの観点からは、単一の発送ではなく複数の発送のリストを発送コンテキストへ送ることだけが変更点となる
    • 顧客は、発送されたかどうか、支払いがすべて終わっているかなど、注文の状態を見られるようになるかもしれない
      • 「カスタマーサービス」のような新しいコンテキストを作成する
      • 他のコンテキストからのイベントを受け取り、それに応じて状態を更新する

まとめ

  • 本章では、型駆動のドメインモデリングと、関数からワークフローを作成する合成のアプローチのメリットを理解した
    • 型駆動の設計のメリット
      • あるドメイン型に新しいフィールドを追加するとコンパイラエラーが発生するので、コードを修正することを強制できる
        • VipStatusを追加する例
      • 依存関係の1つを変更したときも、多くのコンパイラエラーが発生する
        • プロモーションコードの追加の例
          • GetProductPrice -> GetPricingFunctionに変更
    • 関数合成を使ってワークフローを構築するメリット
      • 新しいセグメントを追加しても、他のセグメントはそのままにしておける
        • 既存コードを変更しないので、バグ発生の可能性を減らすことができる
    • インターフェースとしての関数型のメリット
      • 既存のコードとのプラグインの互換性を維持しながら、関数全体を効率的に変更できる
        • 営業時間の制約の追加の例

本書のまとめ

  • 境界づけられたコンテキストという抽象概念への考察から、シリアライズフォーマットのような細部までカバーしてきた
    • 取り上げられなかったトピック
      • Webサービス
      • セキュリティ
      • 運用上の透明性
    • もっとも重要なプラクティス
      • 詳細な設計を始める前に、ドメインに関する深い共有理解を目指すべき
        • イベントストーミングやユビキタス言語の活用が役にたつ
      • 解決空間は独立して進化できるように、自律的で分離された境界づけられたコンテキストに分割するべき
        • ワークフローは、明示的な入出力をもつ独立したパイプラインとして表現するべき
      • コードを書く前に、ドメインの名詞と動詞を型ベースの表記法で把握しようとするべき
        • 名詞は代数的型システムとなる
        • 動詞は関数となる
      • 重要な制約やビジネスルールは、可能な限り型システムで表現する
        • 不正な状態は表現できないようにするため
      • 純粋かつ完全であるように関数を設計していく
        • 入力には明示的に文書化された出力があり、すべての動作は完全に予測可能になる
    • 注文確定ワークフローでは最終的に型の集合が作成された
    • 実装の過程での関数型プログラミングの重要な技術
      • 小さな関数の組み合わせだけで、完全なワークフローを構築する
      • 依存関係や後回しにしたい判断がある場合、関数をパラメーター化する
      • 部分適用を使用して依存関係を関数に組み込み、関数をより簡単に合成できるようにするとともに、不要な実装の詳細を隠す
      • 他の関数をさまざまな形に変換できる特殊な関数を作成する
        • bindアダプターブロック
          • エラーを返す関数を、2トラックの関数に変換する
      • 異なる型を共通の型に「持ち上げ」ることで、型が一致しない課題を解決する

Discussion