🙆‍♀️

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

2024/12/23に公開

https://www.amazon.co.jp/関数型ドメインモデリング-ドメイン駆動設計とF-でソフトウェアの複雑さに立ち向かおう-Scott-Wlaschin/dp/4048931164

01からの続き

型を使ったワークフローの各ステップのモデリング

  • 型を使ったワークフローの各ステップのモデリング
    • 検証のステップ
      • 「注文の検証」サブステップ
          substep "ValidateOrder" =
            input: UnvalidatedOrder
            output:ValidatedOrder OR ValidationError
            dependencies: CheckProductCodeExists, CheckAddressExists
        
      • 依存関係をどのようにモデル化するか
        • 依存関係のモデル化
            type CheckProductCodeExists =
              ProductCode -> bool
            
            type CheckedAddress = CheckedAddress of UnvalidatedAddress
            
            type AddressValidationError = AddressValidationError of string
            
            type CheckAddressExists =
              UnvalidatedAddress -> Result<CheckedAddress, AddressValidationError>
            
            type ValidateOrder =
              CheckProductCodeExists // 依存関係
                -> CheckAddressExists // 依存関係
                -> UnvalidatedOrder // 入力
          
          • 部分適用を容易にするために、依存関係を最初に置いている
    • 価格設定のステップ
      • 「価格設定」サブステップ
          substep "PriceOrder" =
            input: ValidatedOrder
            output: PricedOrder
            dependencies: GetProductPrice
          
          type GetProductPrice =
            ProductCode -> Price
          
          type PriceOrder =
            GetProductPrice // 依存関係
              -> ValidatedOrder // 入力
              -> PriceOrder // 出力
        
    • 注文確認ステップ
      • 「注文確認」サブステップ
          type HtmlString =
            HtmlString of string
          
          type OrderAcknowledgment = {
            EmailAddress : EmailAddress
            Letter : HtmlString
          }
          
          type CreateOrderAcknowledgmentLetter =
            PricedOrder -> HtmlString
          
          type SendResult = Sent | NotSent
          
          type SendOrderAcknowledgment =
            OrderAcknowledgment -> SendResult // bool値ではなく、より意味のある型を返すようにする
          
          type OrderAcknowledgmentSent = {
            OrderId : OrderId
            EmailAddress : EmailAddress
          }
          
          type AcknowledgeOrder =
            CreateOrderAcknowledgmentLetter // 依存関係
              -> SendOrderAcknowledgment // 依存関係
              -> PricedOrder // 入力
              -> OrderAcknowledgmentSent option // 出力
        
    • 返すイベントを作る
      • 発送用のOrderPlaced(注文が確定した)イベントと、請求書用のBillableOrderPlaced(請求可能な注文が確定した)イベントを作成する必要がある
        • OrderPlacedイベントはPricedOrderのエイリアスで、BillableOrderPlacedはPricedOrderのサブセットとなる
      • 返すイベントを作る
          type OrderPlaced = PricedOrder
          type BillableOrderPlaced = {
            OrderId : OrderId
            BillingAddress : Address
            AmountToBill : BillingAmount
          }
          
          // ❌BAD: 専用の型を作成すると、イベントの追加などの変更が難しくなる
          type PlaceOrderResult = {
            OrderPlaced : OrderPlaced
            BillableOrderPlaced : BillableOrderPlaced
            OrderAcknowledgmentSent : OrderAcknowledgmentSent option
          }
          
          // ✅GOOD: イベントのリストを返すことで、新しいイベントを追加する場合に選択肢に追加できるようになる
          type PlaceOrderEvent =
            | OrderPlaced of OrderPlaced
            | BillableOrderPlaced of BillableOrderPlaced
            | AcknowledgmentSent of OrderAcknowledgmentSent
          
          type CreateEvents =
            PricedOrder -> PlaceOrderEvent list
        
      • 同じイベントがドメイン内の複数のワークフローに現れることがわかった場合、より一般的なOrderTakingDomainEvent(受注ドメインイベント)として、ドメイン内のすべてのイベントの選択型として作ることもできる

エフェクト(副作用)の文書化

  • エフェクト(副作用)の文書化
    • エラーを返すかやI/O処理をするかなどすべての依存関係を再検討して、エフェクトを明示する必要があるかを考える
    • 検証ステップでのエフェクト
      • CheckProductCodeExists(製品コードの存在チェック)とCheckAddressExists(アドレスの存在チェック)がある
      • AsyncResultで表現する例
          // 製品カタログのローカルなキャッシュコピーがある場合
          type CheckProductCodeExists = ProductCode -> bool
          
          // リモートのサービスを呼び出している場合
          type AsyncResult<'success, 'failure> = Async<Result<'success, 'failure>>
          
          type CheckAddressExists =
            UnvalidatedAddress -> AsyncResult<CheckedAddress, AddressValidationError>
          
          type ValidateOrder =
            CheckProductCodeExists // 依存関係
              -> CheckAddressExists // AsyncResultを返す依存関係に変更された
              -> UnvalidatedOrder // 入力
              -> AsyncResult<ValidatedOrder, ValidationError> // 出力もAsyncResultに変更される
        
    • 価格設定ステップでのエフェクト
      • ステップ自体がエラーを返す場合に、Resultで表現する例
          type PricingError = PricingError of string
          
          // PriceOrder(注文の価格計算)ステップ自体がエラーを返す場合
          type PriceOrder =
            GetProductPrice // 依存関係
              -> ValidatedOrder // 入力
              -> Result<PricedOrder, PricingError> // 出力
        
    • 確認ステップでのエフェクト
      • I/O処理を行うが、エラーがあっても無視して成功パスを進みたい場合に、Asyncのみとする例
          // I/O処理を行うが、エラーがあっても無視して成功パスを進みたい場合は、Asyncのみとする
          type SendOrderAcknowledgment =
            OrderAcknowledgment -> Async<SendResult>
          
          type AcknowledgeOrder =
            CreateOrderAcknowledgmentLetter // 依存関係
              -> SendOrderAcknowledgment // Asyncを返す依存関係に変更された
              -> PriceOrder // 入力
              -> Async<OrderAcknowledgmentSent option> // 出力もAsyncに変更される
        

ステップからワークフローを合成する

  • ステップからワークフローを合成する
    • 入力と出力の型の調整が必要となる
    • 入力と出力だけリストアップしたもの
        type ValidateOrder =
          UnvalidatedOrder // 入力
            -> AsyncResult<ValidatedOrder, ValidationError list> // 出力
        
        type PriceOrder =
          ValidatedOrder // 入力
            -> Result<PricedOrder, PricingError> // 出力
        
        type AcknowledgeOrder =
          PricedOrder // 入力
            -> Async<OrderAcknowledgmentSent option> // 出力
        
        type CreateEvents =
          PricedOrder // 入力
            -> PlaceOrderEvent list // 出力
      
      • PriceOrderにはValidatedOrderが必要だが、AsyncResult<ValidatedOrder,...>となっている

依存関係は設計の一部ですか?

  • 依存関係は設計の一部ですか?
    • 依存関係を明確にした例と、隠蔽した例
        // 依存関係を明確にした例
        type ValidateOrder =
          CheckProductCodeExists // 明示的な依存関係
            -> CheckAddressExists // 明示的な依存関係
            -> UnvalidatedOrder // 入力
            -> AsyncResult<ValidatedOrder, ValidationError list> // 出力
        
        // プロセスがどう仕事を遂行するか(どのシステムと連携するか)を隠蔽した例
        type  ValidateOrder =
          UnvalidatedOrder // 入力
            -> AsyncResult<ValidatedOrder, ValidationError list> // 出力
      
    • ガイドラインに沿って考えてみる
      • パブリックAPIで公開されている関数については、依存関係の情報を呼び出し元から隠す
      • 内部で使用される関数については、依存関係を明示する
    • どうするか
      • トップレベルのPlaceOrder(注文確定)ワークフロー関数の依存関係は、呼び出し側が知る必要がないため、公開すべきではない
        • しかし、ワークフロー内部の各ステップは依存関係を明確にすべき
          • 各ステップが実際に必要とするものを文書化できる
          • ステップの依存関係が変化したときは、関数定義を変更できる
      •   type PlaceOrderWorkflow =
            PlaceOrder // 入力
              -> AsyncResult<PlaceOrderEvent list, PlaceOrderError> // 出力
        

パイプラインの完成形

  • パイプラインの完成形
    • パブリックAPI
      • パブリックAPI
          // ---------------------
          // 入力データ
          // ---------------------
          
          type UnvalidatedOrder = {
            OrderId : string
            CustomerInfo : UnvalidatedCustomer
            ShippingAddress : UnvalidatedAddress
          }
          and UnvalidatedCustomer = {
            Name : string
            Email : string
          }
          and UnvalidatedAddress = ...
          
          // ---------------------
          // 入力コマンド
          // ---------------------
          
          type Command<'data> = {
            Data : 'data
            Timestamp : DateTime
            UserId : string
            // etc
          }
          
          type PlaceOrderCommand = Command<UnvalidatedOrder>
          
          // ---------------------
          // パブリックAPI
          // ---------------------
          
          /// 受注確定ワークフローの成功出力
          type OrderPlaced = ...
          type BillableOrderPlaced = ...
          type OrderAcknowledgmentSent = ...
          type PlaceOrderEvent = 
            | OrderPlaced of OrderPlaced
            | BillableOrderPlaced of BillableOrderPlaced
            | AcknowledgmentSent of OrderAcknowledgmentSent
          
          /// 受注確定ワークフローの失敗出力
          type PlaceOrderError = ... 
          
          type PlaceOrderWorkflow =
            PlaceOrderCommand
              -> AsyncResult<PlaceOrderEvent list, PlaceOrderError>
        
    • 内部ステップ
      • 内部ステップ
          // ---------------------
          // 内部ステップの定義
          // ---------------------
          
          // ----- 注文の検証 -----
          
          // 注文の検証が使用するサービス
          type CheckProductCodeExists =
            ProductCode -> bool
          
          type AddressValidationError = ...
          type CheckedAddress = ...
          type CheckAddressExists =
            UnvalidatedAddress
              -> AsyncResult<CheckedAddress, AddressValidationError>
          
          type ValidateOrder =
            CheckProductCodeExists // 依存関係
              -> CheckAddressExists // 依存関係
              -> UnvalidatedOrder // 入力
              -> AsyncResult<ValidatedOrder, ValidationError list> // 出力
          and ValidationError = ...
          
          // ----- 注文の価格計算 -----
          
          // 注文の価格計算が使用するサービス
          type GetProductPrice =
            ProductCode -> Price
          
          type PricingError = ...
          
          type PriceOrder =
            GetProductPrice // 依存関係
              -> ValidatedOrder // 入力
              -> Result<PricedOrder, PricingError> // 出力
          
          // etc
        

長時間稼働するワークフロー

  • 長時間稼働するワークフロー
    • パイプラインは数秒程度の短時間で稼働することを想定していた
    • 例: もし、一日中かかるとしたら
      • リモートサービスを呼び出す前に、状態をストレージに保存する必要がある
      • サービスが終了したことを知らせるメッセージを待って、次のステップに進める必要がある
    • 人間の手作業が含まれるなど、長時間稼働するワークフローをサーガと呼ぶことがある
      • プロセスマネージャーを作成する必要がある場合もある
        • 受信したメッセージを処理して、現在の状態からどのようなアクションを取るべきかを判断し、適切なワークフローを起動させる役割を担う

まとめ

  • 本章では型だけを使ってワークフローをモデル化する方法を学んだ
    • 流れ
      • コマンドをどのようにモデル化するか検討し、ワークフローへの入力を文書化する
      • ステートマシンを使ってライフサイクルをもつエンティティをモデル化する
      • 各ステップの依存関係や副作用の文書化をする
    • ドメインを伝達できるコードとしての、実行可能なドキュメントを作成している
      • 多くの型が必要となる
      • 型がなければ、検証済みの注文と価格設定された注文の違いや、装置コートと通常の文字列の違いを別に文書化する必要が出てきてしまう

次にやること

  • 実際の実装に着手する
    • ウォーターフォールを意図しているわけではない
    • 実際には、顧客やドメインエキスパートへのフィードバックをできるだけ早く得るために必要なことは何でも行うべき
      • 継続的に要件収集、モデリング、プロトタイピングを混ぜ合わせる
    • 型を使ったモデリングの要点は、ドメインエキスパートがモデルを直接読むことができるため、要件とモデリングがシームレスに繋げることができる

第三部: モデルの実装

  • 第三部の内容
    • 第二部でモデル化したワークフローを実装する
    • 一般的な関数型プログラミングのテクニックを使用する

8. 関数の理解

  • 関数型プログラミングの基本を理解する

関数、関数、どこにでも

  • 関数、関数、どこにでも
    • 関数型プログラミングとオブジェクト指向プログラミングの違い
      • 関数型プログラミングとは、関数が非常に重要であるかのようにプログラミングすること
        • 関数があらゆる場所、あらゆるものに使われる
      • パーツを作るとき
        • OOP: クラスやオブジェクトとなる
        • FP: 関数となる
      • プログラムの一部をパラメーター化したり、コンポーネント間の結合を軽減する場合
        • OOP: インターフェースや依存関係の注入を使用する
        • FP: 関数を使ってパラメーター化する
      • DRY原則
        • OOP: 継承やDecoratlrパターンを使用する
        • FP: 関数合成を使う
    • 関数型プログラミングは異なるスタイルの違いではなく、プログラミングに対する考え方がまったく違っていると理解することが重要
      • 既存実装からの疑問が浮かんだ場合、その疑問が本当に解決したかった課題をどう解決するか考えた方がよい結果につながる
        • コレクションをループするのはどうするのか -> コレクションの各要素に対してアクションを実行するにはどうしたらいいか

「もの」としての関数

  • 「もの」としての関数
    • 関数型プログラミングでは、関数自体が「もの」なので、他の関数へ入力として渡すことができる
      • 出力としても返すことができる
    • F#で関数を「もの」として扱う
      • F#で関数を「もの」として扱う例
          let plus3 x = x + 3
          let times2 x = x * 2
          let square = (fun x -> x * x)
          let addThree = plus3
          
          // listOfFunctions : (int -> int) list
          let listOfFunctions =
            [addThree; times2; square]
          
          for f  in listOfFunctions do
            let result = fn 100
            printfn "If 100 is the input, the output is %i" result
          
          // myString : string
          let myString = "hello"
          
          // square : x:int -> int
          let square = (fun x -> x * x)
        
    • 入力としての関数
      • 入力としての関数の例
          let evalWith5ThenAdd2 fn =
            fn(5) + 2
          
          let add1 x = x + 1
          evalWith5ThenAdd2 add1 // fn(5) + 2 は add1(5) + 2 となる
          // よって、5 + 1 + 2 となる
          
          let square x = x * X
          evalWith5ThenAdd2 square // fn(5) + 2 は square(5) + 2 となる
          // よって、5 * 5 + 2 となる
        
    • 出力としての関数
      • 出力としての関数の例
          let add1 x = x + 1
          let add2 x = x + 2
          let add3 x = x + 3
          
          let adderGenerator numberToAdd =
            fun x -> numberToAdd + x
          // val addGenerator : int -> (int -> int)
          
          let adderGenerator numberToAdd =
            let innerFn x =
              numberToAdd + X
            
            innerFn
          
          let add1 = adderGenerator 1
          add1 // 結果 => 3
          
          let add100 = adderGenerator 100
          add100 2 // 結果 => 102
        
    • カリー化
      • 「関数を返す」という技を使えば、どんなにパラメーターが多い関数でも、1パラメーターが連なったものに変換できる
        • これをカリー化という
      • F#では明示的に関数を返さなくてもすべての関数はカリー化されるようになっている
      • カリー化の例
          // int -> int -> int
          let add x y = x + y
          
          // 明示的に関数を返す: int -> (int -> int)
          let adderGenerator x = fun y -> x + y
        
    • 部分適用
      • すべての関数がカリー化されていると、複数のパラメーターを持つ関数に1つだけ引数を渡すと部分適用ができる
      • 部分適用の例
          // sayGreeting: string -> string -> unit
          let sayGreeting greeting name =
            printfn "%s %s" greeting name
          
          // sayHello: string -> unit
          let sayHello = sayGreeting "Hello"
          
          // sayGoodbye: string -> unit
          let sayGoodbye = sayGreeting "GoodBye"
          
          sayHello "Alex"
          // 出力: "Hello Alex"
          
          sayGoodbye "Alex"
          // 出力: "Goodbye Alex"
        

全域関数

  • 全域関数
    • 数学の世界では、関数とは取りうる各入力に対して、それぞれ1つの出力に結びついているものを指す
      • 関数型プログラミングでも、取りうる入力全てについて、対応する出力が1つずつ決まるように関数を設計する
      • これを全域関数と呼ぶ
    • なぜそんなことをやるのか?
      • すべての副作用を型シグネチャで文書化して、明示的にしたいから
      • くだらないtwelveDividedByという関数の例
          // twelveDividedBy: int -> int となってしまう(例外がある)
          let twelveDividedBy n = 
            match n with
            | 6 -> 2
            | 5 -> 2
            | 4 -> 3
            | 3 -> 4
            | 2 -> 6
            | 1 -> 12
            | 0 -> failwith "Can't divide by zero"
          
          /// 制限された入力値を使う場合
          type NonZeroInteger =
            // ゼロではない整数に制約されるように定義する
            // スマートコンストラクタを追加するなど
            private NonZeroInteger of int
          
          // twelveDividedBy: NonZeroInteger -> int
          let twelveDividedBy (NonZeroInteger n) =
            match n with
            | 6 -> 2
            ...
          
          /// 拡張された出力値を使う場合
          // twelveDividedBy: int -> int option
          let twelveDividedBy n =
            match n with
            | 6 -> Some 2 // 有効
            | 5 -> Some 2 // 有効
            | 4 -> Some 3 // 有効
            ...
            | 0 -> None // 未定義
        

関数合成

  • 関数合成
    • 関数合成とは
      • 1つ目の関数の出力を2つ目の関数の入力につなげて、関数を組み合わせること
        • りんご -> バナナ関数とバナナ -> さくらんぼ関数を合成して、りんご -> さくらんぼ関数を作ること
        • 合成の重要な位置側面として、情報の隠蔽がある
          • バナナはどこへいった
    • F#における関数合成
      • F#では最初の関数の出力型と2番目の関数の入力型が同じであれば、2つの関数をくっつけることができる
        • パイピングと呼ばれる方法で行われる
        • Unixでのパイピングに似ている
      • F#でのパイピング
          let add1 x = x + 1 // int -> int 型の関数
          let square x = x * x // int -> int 型の関数
          
          let add1ThenSquare x =
            x |> add1 |> square
          
          // テスト
          add1ThenSquare 5 // 結果は36
          
          let isEven x =
            (x % 2) = 0 // int -> bool 型の関数
          
          let printBool =
            sprintf "value is %b" x  // bool -> string 型の関数
          
          let isEvenThenPrint x =
            x |> isEven |> printBool
          
          // テスト
          isEvenThenPrint 2 // 結果は"value is true"
        
    • 関数からアプリケーション全体を構築する
      • 低レベル処理をいくつかまとめてサービスにして、それをいくつかまとめてワークフローとする
      • できたワークフローを並列に合成して、入力に応じて特定のワークフローを呼び出すコントローラ/ディスパッチャを作成することで、ワークフローからアプリケーションを構築できる
      • これが関数型アプリケーションを構築する方法
        • 各レイヤーは入力と出力をもつ関数で構成され、すべての層が関数で構成されている
    • 関数を合成する上での課題
      • 一方の関数の出力がもう一方の関数の入力と一致しない場合にどうするか
        • よくあるのは基礎となる型は合うが、関数の形式が異なる場合
        • ある関数がOption<int>を出力するが、2つ目の関数はプレーンなintを必要とする場合など
      • 一般的なアプローチは、両サイドの最小公倍数に変換すること
        • intとOption<int>なら、Someを用いて関数Aの出力をOptionに変換する
        • 両サイドの最小公倍数に変換する
            // intを出力とする関数
            let add1 x = x + 1
            
            // Option<int>を入力とする関数
            let printOption x =
              match x with
              | Some i -> printfn "The int is %i" i
              | None -> printfn "No value"
            
            // Someコンストラクタを使ってadd1の出力をOptionに変換する
            5 |> add1 |> Some |> printOption
          

まとめ

  • 本章ではF#における関数型プログラミングの基本的な概念を学んだ
    • あらゆる場所で関数を構成要素として使用し、それらを合成可能に設計すること

9. 実装:パイプラインの合成

  • まだ何も実装はしていない
    • ワークフローは一連のドキュメント変換、すなわちパイプラインと考えられ、パイプラインの各ステップは「パイプ」として設計されている
    • パイプラインの段階
      • UnvalidatedOrder(未検証の注文)をValidatedOrder(検証済みの注文)に変換し、失敗した場合はエラーを返す
      • ValidatedOrderをPricedOrderに変換する
      • 価格設定ステップの出力を取得し、確認書を作成して送信する
      • 一連のイベントを作成して返す
    • パイプラインの見た目
        let placeOrder unvalidatedOrder =
          unvalidatedOrder
          |> ValidateOrder
          |> PriceOrder
          |> AcknowledgeOrder
          |> createEvents
      
  • 元の要件を維持したまま、技術的な詳細に引きずられることなくコード化したい
    • 個々のステップを作成して、ワークフローを実装する
      • 各ステップをステートレスで副作用のない独立した関数として実装する
      • これらの小さな関数を1つの関数に合成する
    • 実際にやってみるときの注意点
      • 各ステップの入力と出力を操作して合成できるようにする必要がある
      • 一部の関数には、データパイプラインの一部ではないものの実装には必要となる、追加のパラメーターがある
        • これらは依存関係と呼ばれる
      • エラー処理などのエフェクトへの対応となるResult型を、プレーンなデータを入力とする関数に結びつけられるようにする
  • 本章では「依存性の注入」と同等のことを関数型プログラミングで実現する方法を見ていく
    • いったんResultやAsyncなどのエフェクトの扱いはおいておく

単純型を扱う

  • 単純型を扱う
    • ワークフロー自体のステップを実装する前に、まずOrderId(注文ID)やProductCode(製品コード)などの単純型を実装する
      • ほとんどの型が制約を受けているので、制約付きの型を実装する
      • 例: OrderId.createはstringから制約付きのOrderIdを作成する
      •   module Domain =
            type OrderId = private OrderId of string
          
            module OrderId =
              /// 注文IDのスマートコンストラクタを定義する
              /// string -> string
              let create str =
                if String.IsNullOrEmpty(str) then
                  // ひとまずResultではなく例外を使う
                  failwith "OrderId must not be null or empty"
                elif str.Length > 50 then
                  failwith "OrderId must not be more than 50 chars"
                else
                  OrderId str
          
              /// 注文IDの内部値を取り出す
              /// OrderId -> string
              // パターンマッチと内部値の抽出をワンステップでおこなっている
              let value (OrderId str) = // パラメーターのところですでにアンラップしている
                str // 内部値を返す
        

関数の型から実装を導く

  • 関数の型から実装を導く
    • コードが型に準拠していることを確実にするには
      • 通常の方法で関数を定義して、後で使用するときに型エラーが出ると信じる方法(型推論を使用する)
        • 関数を実装した後で組み合わせる際にエラーが発生する
        •   let ValidateOrder
              checkProductCodeExists // 依存関係
              checkAddressExists // 依存関係
              unvalidatedOrder =  // 入力
                ...
          
      • 関数シグネチャを定義して特定の型を実装していることを明確にする方法
        • 関数を組み合わせたときにエラーになるのではなく、関数定義の中でローカルにエラーが発生する
        •   // 関数シグネチャを定義する
            type MuFunctionSignature = Param1 -> Param2 -> Result
            
            // シグネチャを実装する関数を定義する
            let myFunc: MyFunctionSignature = 
              fun param2 param2
            
            // validateOrderの例
            let validateOrder : ValidateOrder =
              fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
                ...
          

検証ステップの実装

  • 検証ステップの実装
    • プリミティブなフィールドを持つ未検証の注文を、検証済みの適切なドメインオブジェクトに変換する
      • ドメインオブジェクトに変換する
          let validateOrder: ValidateOrder =
            fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
          
            let orderId =
              unvalidatedOrder.OrderId
              |> OrderId.create
            
            let customerInfo =
              unvalidatedOrder.CustomerInfo
              |> toCustomerInfo // ヘルパー関数
          
            let shippingAddress =
              unvalidatedOrder.ShippingAddress
              |> toAddress // ヘルパー関数
          
            // unvalidatedOrderの各プロパティに対して同様に行う
          
            // すべてのフィールドの準備ができたら、それらを使って
            // 新しい「検証済みの注文」レコードを作成して返す
            {
              OrderId = orderId
              CustomerInfo = customerInfo
              ShippingAddress = shippingAddress
              BillingAddress = ...
              Lines = ...
            }
          
        let toCustomerInfo (customer:UnvalidatedCustomerInfo) : CustomerInfo =
          // 顧客情報の各種プロパティを作成すr
          // 無効な場合は例外をスローする
          let firstName = customer.FirstName |> String50.create
          let lastName = customer.LastName |> String50.create
          let emailAddress = customer.EmailAddress |> EmailAddress.create
        
          // 個人名を作成すr
          let name : PersonalName = {
            FirstName = firstName
            LastName = lastName
          }
        
          // 顧客情報を作成する
          let customer Info : CustomerInfo = {
            Name = name
            EmailAddress = emailAddress
          }
          // そしてそれを返す
          customerInfo
        
    • 検証されたチェック済みの住所の作成
      • 生のプリミティブ型をドメインオブジェクトに変換し、住所が存在するかどうかもチェックする必要がある
      • toAddress関数の実装
          let toAddress (checkAddressExists unvalidatedAddress) =
            // リモートサービスを呼び出す
            let checkedAddress = checkAddressExists unvalidatedAddress
            // パターンマッチを使用して内部値を抽出する
            let (CheckedAddress checkedAddress) = checkedAddress
          
            let addressLine1 =
              checkedAddress.AddressLine1 |> String50.create
            let addressLine2 =
              checkedAddress.AddressLine2 |> String50.createOption // createOptionでnullや空文字列を許容する
            let addressLine3 =
              checkedAddress.AddressLine3 |> String50.createOption
            let addressLine4 =
              checkedAddress.AddressLine4 |> String50.createOption
            let city =
              checkedAddress.City |> String50.create
            let zipCode =
              checkedAddress.ZipCode |> ZipCode.create
            // 住所を作成すr
            let address : Address = {
              AddressLine1 = addressLine1
              AddressLine2 = addressLine2
              AddressLine3 = addressLine3
              AddressLine4 = addressLine4
              City = city
              ZipCode = zipCode
            }
          // 住所を返す
          address
          
          let validateOrder : ValidateOrder =
            fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
              let orderId = ...
              let customerInfo = ...
              let shippingAddress = 
                unvalidatedOrder.ShippingAddress
                |> toAddress checkAddressExists // 新しいパラメーター
        
    • 明細行の作成
      • toOrderQuantityでは、生の10進数をケースごとに異なる検証を行って選択型として返す
      • 明細行の作成
          let toValidatedOrderLine checkProductCodeExists
          (unvalidatedOrderLine:UnvalidatedOrderLine) =
            let orderLineId =
              unvalidatedOrderLine.OrderLineId
              |> OrderLineId.create
            let productCode =
              unvalidatedOrderLine.ProductCode
              |> toProductCode checkProductCodeExists // ヘルパー関数
            let quantity =
              unvalidatedOrderLine.quantity
              |> toOrderQuantity productCode // ヘルパー関数
            let validatedOrderLine = {
              OrderLineID = orderLineId
              ProductCode = productCode
              Quantity = quantity
            }
            validatedOrderLine
          
          let validateOrder : ValidateOrder =
            fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
              let orderId = ...
              let customerInfo = ...
              let shippingAddress = ...
          
              let orderLines =
                unvalidatedOrder.OrderLines
                // `toValidatedOrderLine`を用いて各行を変換する
                |> List.map (toValidatedOrderLine checkProductCodeExists)
          
          let toOrderQuantity productCode quantity =
            match productCode with
            | Widget _ ->
              quantity
              |> int // decimalをintに変換
              |> UnitQuantity.create // ユニット数に変換
              |> OrderQuantity.Unit // OrderQuantity型に持ち上げる
            | Gizmo _ ->
              quantity
              |> KilogramQuantity.create // キログラム量に変換
              |> OrderQuantity.Kilogram // OrderQuantity型に持ち上げる
        
    • 関数アダプターの作成
      • checkProductCodeExistsがbool値を返すため、ProductCodeを返すことができないのをどうするか?
        • check関数を引数として渡して判定する場合に、判定後の戻り値がパイプラインで想定していないbool値になってしまう問題
        • bool値を返すため、ProductCodeを返すことができない
            let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
              productCode
              |> ProductCode.create
              |> checkProductCodeExists
              // boolを返すことになる
          
      • 仕様を変更するのではなくアダプター関数を作成する
        • 判定を抜けられれば入力値をそのまま返す(パススルー)アダプター関数
        • パススルー関数を使うのは、関数型プログラミングではよくある方法
        •   let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
              let checkProduct productCode =
                let errorMsg = sprintf "Invalid: %A" productCode
                predicateToPassthru errorMsg checkProductCodeExists productCode
            
              productCode
              |> ProductCode.create
              |> checkProduct
            
            // パススルー関数
            let convertToPassthru checkProductCodeExists productCode =
              if checkProductCodeExists productCode then
                productCode
              else
                failwith "Invalid product Code"
            
            // 任意の述語関数をパススルー関数に変換する汎用アダプター
            let predicateToPassthru errorMsg f x =
              if f x then
                x
              else
                failwith errorMsg
          

残りのステップの実装

  • 残りのステップの実装
    • priceOrderの実装
      • priceOrderの実装
          // 元の設計からエフェクト(副作用)を取り除いたもの
          type GetProductPrice = ProductCode -> Price
          type PriceOrder =
            GetProductPrice // 依存関係
              -> ValidatedOrder // 入力
              -> PricedOrder // 出力
          
          let priceOrder : PriceOrder =
            fun getProductPrice validatedOrder ->
              let lines =
                validatedOrder.Lines
                |> List.map(toPricedOrderLine getProductPrice)
              let amountToBill =
                lines
                |> List.map(fun line -> line.LinePrice)
                |> BillingAmount.sumPrices
              let pricedOrder : PricedOrder = {
                OrderId = validatedOrder.OrderId
                CustomerInfo = validatedOrder.CustomerInfo
                ShippingAddress = validatedOrder.ShippingAddress
                BillingAddress = validatedOrder.BillingAddress
                Lines = lines
                AmountToBill = amountToBill
              }
          
            pricedOrder
        
          /// 価格のリストを合計して請求総額にする
          /// 合計が範囲外の場合は例外を発生させる
          let sumPrices prices =
            let total = prices |> List.map Price.value |> List.sumBy
            create total
          
          /// 検証済みの注文明細行を価格計算済みの注文明細行に変換する
          let toPricedOrderLine getProductPrice (line:ValidatedOrderLine) : PricedOrderLine =
            let qty = line.Quantity |> OrderQuantity.ValidationError
            let price = line.ProductCode |> getProductPrice
            let linePrice = price |> Price.multiply qty
            {
              OrderLIneId = line.OrderLineID
              ProductCode = line.ProductCode
              Quantity = line.Quantity
              LinePrice = linePrice
            }
          
          /// 価格に十進数の数量をかける
          /// 新しい価格が範囲外の場合、例外を発生させる
          let multiply qty (Price p) = 
            create (qty * p)
        
    • まだ実装したくない・わからない場合は、「実装されていない」メッセージを出して失敗させることもできる
      • 実装のスケッチをするときに、プロジェクト全体がコンパイルが可能になるので便利
      • ダミーの関数を作成して組み合わせておくことができる
      • まだ実装したくない場合
          let priceOrder : PriceOrder =
            fun getProductPrice validatedOrder ->
              failwith "not implemented"
        
    • 確認ステップの実装
      • sendAcknowledgmentはまだ実装を決めなくてもいい
        • 依存関係をパラメーター化する利点の1つ
      •   // エフェクトを排除した「確認ステップ」の設計
          type HtmlString = HtmlString of string
          type CreateOrderAcknowledgmentLetter =
            PricedOrder -> HtmlString
          
          type OrderAcknowledgment = {
            EmailAddress : EmailAddress
            Letter : HtmlString
          }
          type SendResult = Sent | NotSent
          type SendOrderAcknowledgment =
            OrderAcknowledgment -> SendResult
          
          type AcknowledgeOrder =
            CreateOrderAcknowledgmentLetter // 依存関係
              -> SendOrderAcknowledgment // 依存関係
              -> PricedOrder // 入力
              -> OrderAcknowledgmentSent option // 出力
          
          // 実装
          let acknowledgeOrder : AcknowledgeOrder =
            fun createOrderAcknowledgmentLetter sendAcknowledgment pricedOrder ->
              let letter createAcknowledgmentLetter pricedOrder
              let acknowledgment = {
                EmailAddress = pricedOrder.CustomerInfo.EmailAddress
                Letter = letter
              }
          
              // 確認が正常に送信された場合、対応するイベントを返す
              // そうでなければNoneを返す
              match sendAcknowledgment acknowledgment with
                | Sent ->
                  let event = {
                    OrderId = pricedOrder.OrderId
                    EmailAddress = pricedOrder.CustomerInfo.EmailAddress
                  }
                  Some event
                | NotSent ->
                  None
        
    • イベントの作成
      • ワークフローから返されるイベントを作成する
      • 互換性のないものを持ち上げて共通の型に変換するというアプローチをとる
      • 持ち上げてイベントを作成する
          /// 配送コンテキストに送信するイベント
          type OrderPlaced = PricedOrder
          
          /// 請求コンテキストに送信するイベント
          /// 請求総額が0ではない場合にのみ作成される
          type BillableOrderPlaced = {
            OrderId : OrderId
            BillingAddress : Address
            AmountToBill : BillingAmount
          }
          
          type PlaceOrderEvent =
            | OrderPlaced of OrderPlaced
            | BillableOrderPlaced of BillableOrderPlaced
            | AcknowledgmentSent of OrderAcknowledgmentSent
          
          type CreateEvents =
            PricedOrder // 入力
              -> OrderAcknowledgmentSent option // 入力(前のステップのイベント)
              -> PlaceOrderEvent list // 出力
          
          // PricedOrder -> BillableOrderPlaced option
          let createBillingEvent (placedOrder:PricedOrder) : BillableOrderPlaced option =
            let billingAmount = placedOrder.AmountToBill |> BillingAmount.value
            if billingAmount = 0M then
              let order = {
                OrderId = placedOrder.OrderId
                BillingAddress = placedOrder.BillingAddress
                AmountToBill = placedOrder.AmountToBill
              }
              Some order
            else 
              None
          
          let createEvents : CreateEvents =
            fun pricedOrder acknowledgmentEventOpt ->
              let event1 =
                pricedOrder
                // 共通の選択型に変換する
                |> PlaceOrderEvent.OrderPlaced
                // リストに変換する
                |> List.singleton
              let event2Opt =
                acknowledgmentEventOpt
                // 共通の選択型に変換すr
                |> Option.map PlaceOrderEvent.AcknowledgmentSent
                // リストに変換する
                |> listOfOption
              let event3Opt =
                pricedOrder
                |> createBillingEvent
                // 共通の選択型に変換する
                |> Option.map PlaceOrderEvent.BillableOrderPlaced
                // リストに変換する
                |> listOfOption
          
              // 全てのイベントを返す
              [
                yield! event1
                yield! event2
                yield! event3
              ]
          
          // Option型をList型に変換する
          let listOfOption opt =
            match opt with
            | Some x -> [x]
            | None -> []
        

パイプラインのステップを1つに合成する

  • パイプラインのステップを1つに合成する
    • そのままだと入力が一致しないときは、部分適用して変形させた関数を作成する(シャドーイング)
    • シャドーイングの例
        // ワークフロー
        let placeOrder : PlaceOrderWorkflow =
          fun unvalidatedOrder ->
            unvalidatedOrder
            |> validateOrder
            |> priceOrder
            |> acknowledgeOrder
            |> createEvents
        
        // validateOrderには依存関係が含まれているため、unvalidatedOrderだけでは呼び出せない
        // 部分適用して型を合わせる
        // F#では同じ名前(validateOrder)を局所的な下らしい関数にも使用できる(シャドーイング)
        let validateOrder =
          validateOrder checkProductCodeExists checkAddressExists
        
        // アポストロフィをつけて元の関数の変形だと明示することもできる
        let validateOrder' =
          validateOrder checkProductCodeExists checkAddressExists
        
        let placeOrder : PlaceOrderWorkflow =
          // 部分適用を使って依存関係を組み込んだ、
          // パイプラインステージの局所的なパージョンを作成する
          let validateOrder =
            validateOrder checkProductCodeExists checkAddressExists
          let priceOrder =
            priceOrder getProductPrice
          let acknowledgeOrder =
            acknowledgeOrder createOrderAcknowledgmentLetter sendOrderAcknowledgment
        
          // ワークフロー関数を返す
          fun unvalidatedOrder ->
            // 新しい1引数関数でパイプラインを合成する
            unvalidatedOrder
            |> validateOrder
            |> priceOrder
            |> acknowledgeOrder
            |> createEvents
        
        // acknowledgeOrderの出力が単なるイベントなので、
        // createEventsの入力とは一致しない
        // そのため、単純にもっと命令型のコードに切り替えることもできる
        let placeOrder : PlaceOrderWorkflow =
          fun unvalidatedOrder ->
            let validatedOrder =
              unvalidatedOrder
              |> validateOrder checkProductCodeExists checkAddressExists
            let pricedOrder =
              validatedOrder
              |> priceOrder getProductPrice
            let acknowledgmentOption =
              pricedOrder
              |> acknowledgeOrder createAcknowledgmentLetter sendAcknowledgment
            let events =
              createEvents pricedOrder acknowledgmentOption
            events
      

依存関係の注入

  • 依存関係の注入
    • 依存関係をどうやって渡すか
      • checkProductCodeExistscheckAddressExistsの依存関係をどうするか
        • グローバルには定義したくない
        • トップレベルから依存関係を必要とする関数まで、どうやって受け渡していくか
      • OOPでは依存関係の注入やIoCコンテナを使う
        • 関数型プログラミングでは依存関係が暗黙的になるのは望ましくない
        • リーダーモナドやフリーモナドなどのさまざまな手法がある
        • シンプルにするには、ドリリングのようにトップレベルからバケツリレーしていく方法
          • 実装時はトップレベルの関数に到達するまで連鎖を繰り返す
        • 依存関係のバケツリレーの例
            // 低レベルのヘルパー関数
            let toAddress checkAddressExists unvalidatedAddress =
              ...
            
            let toProductCode checkProductCodeExists productCode =
              ...
            
            // ヘルパー関数
            let toValidatedOrderLine checkProductExists unvalidatedOrderLine =
              // 明細行のコンポーネントを作成する
              let orderLineId = ...
              let productCode =
                unvalidatedOrderLine.ProductCode
                |> toProductCode checkProductCodeExists // サービスを利用する
          
      • ワークフロー関数としてのplaceOrderをコンポジションルートにすべきか?(トップレベルの関数はOOPではコンポジションルートと呼ばれる)
        • すべきではない
        • placeOrderワークフロー自体も、必要なサービスをパラメーターとして受け取る方がよい
        • そうすれば、すべての依存関係を差し替えられるため、ワークフロー全体を簡単にテストできる
        • Suaveフレームワークを使ったWebサービスのコンポジションルートの例
            let app : WebPart =
              // ワークフローで使用するサービスの設定
              let checkProductExists = ...
              let checkAddressExists = ...
              let getProductPrice = ...
              let createOrderAcknowledgmentLetter = ...
              let sendOrderAcknowledgment = ...
              let toHttpResponse = ...
            
              // サービスの部分適用によるplaceOrderワークフローの設定
              let placeOrder =
                placeOrder
                  checkProductExists
                  checkAddressExists
                  getProductPrice
                  createOrderAcknowledgmentLetter
                  sendOrderAcknowledgment
            
              // 他のワークフローの設定
              let changeOrder = ...
              let cancelOrder = ...
            
              choose
                [ POST >=> choose
                  [ path "/placeOrder"
                      >=> deserializeOrder // JSONを未検証の注文に変換する
                      >=> placeOrder // ワークフローを実行する
                      >=> postEvents // イベントをキューに投入する
                      >=> toHttpResponse // 出力に基づき200/400/etcを返す
                    path "/changeOrder"
                      >=> ...
                    path "/cancelOrder"
                      >=> ...
                  ]
                ]
          
    • 依存関係が多すぎるとき
      • 低レベルの関数をトップレベル関数の外側で設定し、すべての依存関係を組み込んだ上で構築済みの子関数を渡す方法がある
      • 構築済みの子関数を渡す例
          let placeOrder : PlaceOrderWorkflow =
            // (構成情報を元にするなどして)情報を初期化する
            let endPoint = ...
            let credentials = ...
          
            // 認証情報を組み込んだ
            // checkAddressExistsの新しいバージョンを作成する
            let checkAddressExists = checkAddressExists endPoint credentials
            // etc
          
            // ワークフローのステップを設定する
            let validateOrder =
              validateOrder checkProductCodeExists checkAddressExists
              // 新しいcheckAddressExistsは1引数の関数として使用される
          
            // ワークフロー関数を返す
            fun unvalidatedOrder ->
              // ステップからパイプラインを合成する
              ... 
        

依存関係のテスト

  • 依存関係のテスト
    • 依存関係を渡す利点はコア機能のテストが簡単になること
      • スタブの依存関係を渡すだけでよいので、モックを使う必要がない
    • スタブした依存関係のテストの例
        open NUnit.Framework
        
        [<Test>]
        let ``製品が存在する場合は、検証に成功する``() =
          // 準備: サービス依存関係のスタブを設定する
          let checkAddressExists address =
            CheckedAddress address // 成功
        
          let checkProductCodeExists productCode =
            true // 成功
        
          // 準備: 入力の設定
          let unvalidatedOrder = ...
        
          // 実行: validateOrderを呼び出す
          let result = validateOrder checkProductCodeExists checkAddressExists ...
        
          // 検証: 結果がエラーではなく、検証済みの注文であることを確認する
          ...
        
        
        [<Test>]
        let ``製品が存在しない場合は、検証に失敗する``() =
          // 準備: サービスの依存関係のスタブを設定する
          let checkAddressExists address = ...
          let checkProductCodeExists productCode =
            false // 失敗
        
          // 準備: 入力の設定
          let unvalidatedOrder = ...
        
          // 実行: validateOrderを呼び出す
          let result = validateOrder checkProductCodeExists checkAddressExists ...
        
          // 検証: 結果が失敗であることを確認する
          ...
      
    • 関数型プログラミングのテストのメリット
      • validateOrder関数はステートレスなため、同じ入力で呼び出すと同じ出力が得られるためテストが簡単になる
      • すべての依存関係が明示的に渡されるため、動作の理解が容易になる
      • すべての副作用はパラメーターにカプセル化されているため、制御が簡単になる

組み立てられたパイプライン

  • 組み立てられたパイプライン
    • 完全なパイプラインをどう組み立てるか
      • パイプラインの完全な実装
      • パイプラインの例
          module PlaceOrderWorkflow =
          
            // 共通の単純型(String50や製品コードなど)を利用できるようにする
            open SimpleType
          
            // 呼び出し元に公開されるパプリック型を利用できるようにする
            open API
          
            // ===============================
            // パート1: 設計
            // ===============================
          
            // ワークフローのパプリックな部分(API)は、
            // `PlaceOrderWorkflow`関数やその入力である`UnvalidatedOrder`のように
            // 別の場所で定義されている
            // 以下の型はワークフロー実装に対しプライベートである
          
            // ----- 注文の検証 -----
            
            type CheckProductCodeExists =
              ProductCode -> bool
            type CheckedAddress =
              CheckedAddress of UnvalidatedAddress
            type CheckAddressExists =
              UnvalidatedAddress -> CheckedAddress
            type ValidateOrder =
              CheckProductCodeExists // 依存関係
                -> CheckAddressExists // 依存関係
                -> UnvalidatedOrder // 入力
                -> ValidatedOrder // 出力
          
            // ----- 注文の価格決定 -----
          
            type GetProductPrice = ...
            type PriceOrder = ...
            // etc
          
            // ===============================
            // パート2: 実装
            // ===============================
          
            // --------------------------------
            // 注文の検証: 実装
            // --------------------------------
          
            let toCustomerInfo (unvalidatedCustomerInfo: UnvalidatedCustomerInfo) = ...
          
            let toAddress (checkAddressExists: CheckAddressExists) unvalidatedAddress = ...
          
            let predicateToPassthru = ...
          
            let toProductCode (checkProductCodeExists: CheckProductCodeExists) productCode = ...
          
            let toOrderQuantity productCode quantity = ...
          
            let toValidatedOrderLine checkProductExists (unvalidatedOrderLine: UnvalidatedOrderLine) = ...
          
            /// 注文の検証ステップの実装
            let validateOrder : ValidateOrder =
              fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
                let orderId =
                  unvalidatedOrder.OrderId
                  |> OrderId.create
                let customerInfo = ...
                let shippingAddress = ...
                let billingAddress = ...
                let lines =
                  unvalidatedOrder .Lines
                  |> List.map (toValidatedOrderLine checkProductCodeExists)
                let validatedOrder : ValidateOrder = {
                  OrderId = orderId
                  CustomerInfo = customerInfo
                  ShippingAddress = shippingAddress
                  BillingAddress = billingAddress
                  Lines = lines
                }
                validatedOrder
          
            // --------------------------------
            // ワークフローの全体像
            // --------------------------------
            let placeOrder
              checkProductExists // 依存関係
              checkAddressExists // 依存関係
              getProductPrice // 依存関係
              createOrderAcknowledgmentLetter // 依存関係
              sendOrderAcknowledgment // 依存関係
              : PlaceOrderWorkflow =  // 関数の定義
          
              fun unvalidatedOrder ->
                let validatedOrder =
                  unvalidatedOrder
                  |> validateOrder checkProductExists checkAddressExists
                let pricedOrder =
                  validatedOrder
                  |> priceOrder getProductPrice
                let acknowledgmentOption =
                  pricedOrder
                  |> acknowledgeOrder createOrderAcknowledgmentLetter sendOrderAcknowledgment
                let events =
                  createEvents pricedOrder acknowledgmentOption
                events
        

まとめ

  • 本章ではパイプラインの各ステップの実装と依存関係の処理に焦点を当てた
    • 各ステップについて
      • 各ステップの実装は、1つのインクリメンタルな変換に絞っている
      • 単独で理解しやすく、テストしやすい
    • ステップの合成の際に型が一致しない場合のテクニック
      • アダプター関数: アダプター関数を使用して、boolからProductCodeなどに変換する
      • 共通型に持ち上げる: イベントを共通のPlaceOrderEvent型に変換する
      • 部分適用を使用して依存関係を型に組み込む: 関数合成をより簡単にしつつ、実装の詳細を呼び出し側から隠す
  • 何が残っているか
    • エフェクト処理を避け、エラー処理に例外を使用した
    • 関数合成には便利だが、ドキュメントの観点では最悪
      • ごまかしのある関数シグネチャになってしまっている

10. 実装: エラーの扱い

  • エラー処理に対する関数型のアプローチを探る
    • 煩雑な条件分岐やtry/catch地獄でコードを汚染しない
    • ある種のエラーをドメインエラーとして扱う

Result型を使ってエラーを明示する

  • Result型を使ってエラーを明示する
    • 関数型プログラミングの手法は、できるだけものごとを明示的にすることを重視する
      • 残念だがコード内でエラーが2級市民のように扱われてしまうことがよくある
      • 本番稼働できる強固なシステムにするにはエラーを1級市民として扱うべき
      • 特にドメインの一部であるエラーについてはなおさら
    • エラーを含めて、起こりうる結果がすべて型シグネチャによって明示的に文書化されている必要がある
      • エラーを明示的に文書化する
          // エラーが明示的ではない
          type CheckAddressExists = 
            UnvalidatedAddress -> CheckedAddress
          
          // エラーが明示的なバージョン
          type CheckAddressExists =
            UnvalidatedAddress -> Result<CheckedAddress, AddressValidationError>
          
          and AddressValidationError =
            | InvalidFormat of string
            | AddressNotFound of string
        
      • シグネチャを見るだけで多くのことを理解できる
      • 明示的になっているドキュメント内容
        • 入力は未検証の住所である
        • 検証が成功すると出力はチェック済みの住所となる
        • 検証が成功しなかった場合の理由は、フォーマットが無効だったか、アドレスが見つからなかったかのどちらかである

ドメインエラー扱う

  • ドメインエラーを扱う
    • エラーをグループ化する
      • エラーの種類
        • ドメインエラー: ビジネスプロセスの一部として予想されるエラーで、ドメイン設計に含まれているもの
          • 例: 請求の段階で却下された注文、無効な製品コードを含む注文など
        • パニック: 処理不可能なシステムエラー(メモリ不足など)やプログラマーの見落としによるエラー(null参照やゼロによる除算など)、システムを不明な状態にするエラー
        • インフラエラー: アーキテクチャの一部のエラーであり、ビジネスプロセスやドメインにも含まれない、ネットワークタイムアウトや認証失敗など
      • それぞれのエラーの対処方法
        • ドメインエラー
          • ドメインエラーかどうかわからないときは、ドメインエキスパートに聞いてみる
            • 「ねえオリー、ロードバランサーへの接続が中断されたとき、何か気になることはある?」オリー「???」
          • ドメインエラーはドメインモデリングに組み込んで、可能であれば型システムで文書化する
        • パニック
          • パニックは、ワークフローを放棄し例外を発生させ、適切かつもっとも高いレベル(アプリケーションのmain関数など)で補足することが最善の対処方法
          • 最も高いレベルで例外を補足する例
              /// 悪い入力を受け取るとパニックするワークフロー
              let workflowPart2 input =
                if input = 0 then
                  raise (DivideByZeroException())
                  ...
              
              /// ワークフローからすべての例外をトラップする
              /// アプリケーションのトップレベル関数
              let main() =
                // すべてのワークフローをtry/withブロックで囲む
                try
                  let result1 = workflowPart1()
                  let result2 = workflowPart2 result1
                  printfn "the result is %A" result2
              
                // トップレベルの例外処理
                with
                | :? OutOfMemoryException ->
                  printfn "exited with OutOfMemoryException"
                | :? DivideByZeroException -
                  printfn "exited with DivideByZerorException"
                | ex ->
                  printfn "exited with %s" ex.Message
            
        • インフラエラー
          • コードが多くマイクロサービスで構成されていれば例外処理がすっきりする
          • モノリシックなアプリケーションなら、より明確にドメインエラーと同様に扱ってもよい
            • これもドメインエキスパートに尋ねるとわかりやすい
              • リモートアドレスの検証サービスが利用できない場合、ビジネスプロセスはどう変更すべきか、顧客にはどう伝えるべきか
              • 例: 今、ご存知のように国のアドレス検証サービスがダウンしているので、もう少々お待ちいただけますか -> ドメインエラーにした方がよさそう
    • 型によるドメインエラーのモデリング
      • ドメインを文字列などのプリミティブ型を使わずにドメイン語彙で表現したように、エラーも同じように扱う
      • エラーを選択型としてモデル化する
        • うまくいかない可能性のあるすべての事柄について、コード内で明示的にドキュメント化できる
          • さらに選択型を拡張できる
          • 個別のエラーへの対応を追加することも容易になる
            • RemoteServiceErrorならリトライするなど
        • エラーを選択型としてモデル化する例
            type PlaceOrderError =
              | ValidationError of string
              | ProductOutOfStock of ProductCode
              | RemoteServiceError of RemoteServiceError
              ...
          
    • エラー処理はコードの見た目を悪くする
      • 例外の利点は成功パスのコードを整然と保てるところ
        • ただ、各ステップでエラーを返すとコードはさらに煩雑になっていってしまう
          • コードの大部分がエラー処理に費やされてしまうこともある
        • 例外でハンドリングする例
            let validateOrder unvalidatedOrder =
              // 潜在的なエラーを条件分岐で処理する例
              let orderResult = ... create order id (or return Error)
              if orderIdResult is Error then
                return
            
              let customerInfoResult = ... create name (or return Error)
              if customerInfoResult is Error then
                return
            
              // 潜在的な例外をtry/withで補足する例
              try
                let shippingAddressResult = ... create valid address (or return Error)
                if shippingAddress is Error then
                  return 
            
                // ...
            
              with
                | ?: TimeoutException -> Error "service timed out"
                | ?: AuthenticationException -> Error "bad credentials"
            
              // etc
          

Resultを生成する関数の連鎖

  • Resultを生成する関数の連鎖
    • Resultを出力する関数は2つの分岐を作る
      • 鉄道にちなんでスイッチ関数と呼ぶ(モナディック関数)
        • 2つのスイッチ関数があれば組み合わせて両方の失敗トラックをパイパスして接続する
        • パイプラインのすべてのステップをつなぐと、成功パスと失敗パスの2トラックの「鉄道指向プログラミング」となる
      • 次の関数の入力と型が合わない問題がある
        • 入力が1つで出力が2つのスイッチ関数を、2トラックの関数に変換する必要がある
        • アダプターブロックを作成することで解決する
    • アダプターブロックの実装
      • スイッチ関数を2トラック関数に変換するアダプターは、関数型プログラミングのツールキットの中でも非常に重要
        • bindやflatMapと呼ばれている
      • どう実装するか
        • 入力はスイッチ関数で、出力は2トラックのみの関数で、2トラックの入力と2トラックの出力をもつラムダとなる
        • 2トラックの入力が成功した場合、その入力をスイッチ関数に渡す
        • 2トラックの入力が失敗の場合は、スイッチ関数をバイパスして失敗を返す
        •   // カリー化されたbind関数
            let bind switchFn =
              fun twoTrackInput ->
                match twoTrackInput with
                | Ok success -> switchFn success
                | Error failure -> Error failure
            
            // カリー化される前のbind関数
            let bind switchFn twoTrackInput =
              match twoTrackInput with
              | Ok success -> switchFn success
              | Error failure -> Error failure
            
            // Resultを受け取るmap関数
            let map f aResult =
              match aResult with
              | Ok success -> Ok (f success)
              | Error failure -> Error failure
          
    • 合成と型チェック
      • 成功パスでは、ステップの出力型が次のステップの入力型と一致する限り、トラックに沿って変更できる
        • FunctionAとFunctionCは型が異なるためbindを使っても直接合成はできない
        • Result型の合成と型チェック
            type FunctionA = Apple -> Result<Bananas, ...>
            type FunctionB = Bananas -> Result<Cherries, ...>
            type FunctionC = Cherries -> Result<Lemon, ...>
            
            let functionA : FunctionA = ...
            let functionB : FunctionB = ...
            let functionC : FunctionC = ...
            
            let functionABC input =
              input
              |> functionA
              |> Result.bind functionB
              |> Result.bind functionC
          
    • 共通のエラー型に変換する
      • 成功パスと違って、エラートラックではトラックに沿ってずっと同じエラー型を持つ
        • 互換性のある共通の型を作成して、mapErrorで変換して合成する
      • mapErrorで合成する例
          let mapError f aResult =
            match aResult with
            | Ok success -> Ok success
            | Error failure -> Error (f failure)
          
          type FunctionA = Apple -> Result<Bananas, AppleError>
          type FunctionB = Bananas -> Result<Cherries, BananasError>
          
          type FruitError =
            | AppleErrorCase of AppleError
            | BananaErrorCase of BananaError
          
          // functionAのResult型をFruitErrorに変換する
          let functionA : FunctionA = ...
          let functionAWithFruitError input =
            input
            |> functionA
            |> Result.mapError (fun appleError -> AppleErrorCase appleError)
          
          // さらに簡略化した例
          let functionAWithFruitError input =
            input
            |> functionA
            |> Result.mapError AppleErrorCase
          
          // 関数Aの型
          Apple -> Result<Bananas, AppleError>
          
          // functionAWithFruitErrorの型
          Apple -> Result<Bananas, FruitError>
          
          // "FruitError"を使用するようにfunctionBを変換する
          let functionBWithFruitError input =
            input |> functionB |> Result.mapError BananaErrorCase
          
          // 新しい関数は"bind"で合成できる
          let functionAB input =
            input
            |> functionAWithFruitError
            |> Result.bind functionBWithFruitError
          
          // 組み合わせたfunctionABのシグネチャ
          val functionAB : Apple -> Result<Cherries, FruitError>
        

パイプラインでのbindとmapの使用

  • パイプラインでのbindとmapの使用
    • Resultに注目して、Asyncエフェクトやサービスの依存関係はいったん無視して再実装してみる
    • 新バージョンのワークフローパイプラインの利点
      • パイプラインの各関数はどれもエラーを生成する可能性があるが、エラーはシグネチャから推測できる
      • 関数は個別にテストできるため予期しない動作が発生しないと確信できる
      • 2トラックモデルを使用しているので、どこかのステップでエラーが発生すると残りの関数がスキップされる
      • 特別なif分岐やtry/catchブロックは存在しない
    • Resultに注目して再実装した例
        type ValidateOrder =
          UnvalidatedOrder // 入力
            -> Result<ValidatedOrder, ValidationError> // 出力
        
        type PriceOrder =
          ValidatedOrder // 入力
            -> Result<PricedOrder, PricingError> // 出力
        
        type AcknowledgeOrder =
          PricedOrder // 入力
            -> OrderAcknowledgmentSent option // 出力
        
        type CreateEvents =
          PricedOrder // 入力
            -> OrderAcknowledgmentSent option // 入力(前ステップのイベント)
            -> PlaceOrderEvent list // 出力
        
        // ValidateOrderとPriceOrderを合成する
        // まず共通のエラー型を作成する
        type PlaceOrderError =
          | Validation of ValidationError
          | Pricing of PricingError
        
        // mapErrorを使用してエラー型を変換する
        let validateOrderAdapted input =
          input
          |> validateOrder // 元の関数
          |> Result.mapError PlaceOrderError.Validation
        let priceOrderAdapted input =
          input
          |> priceOrder // 元の関数
          |> Result.mapError PlaceOrderError.Pricing
        
        // 最終的にbindを使って合成する
        let placeOrder unvalidatedOrder =
          unvalidatedOrder
          |> validateOrderAdapted // 変換後の関数
          |> Result.bind priceOrderAdapted // 変換後の関数
        
        // 1トラックの関数はResult.mapを使用して合成する
        let placeOrder unvalidatedOrder =
          unvalidatedOrder
          |> validateOrderAdapted
          |> Result.bind priceOrderAdapted
          |> Result.map acknowledgeOrder // mapを使って2トラックに変換する
          |> Result.map createEvents // 同じく2トラックに変換する
        
        // placeOrder関数のシグネチャ
        UnvalidatedOrder -> Result<PlaceOrderEvent list, PlaceOrderError>
      
      • まだ実際にはコンパイルできない
        • acknowledgeOrderの出力がcreateEventsの入力と一致していないから

他の種類の関数を2トラックモデルに適合させる

  • 他の種類の関数を2トラックモデルに適合させる
    • 2つの関数に着目する
      • 例外を投げる関数
      • 何も返さない行き止まりの関数
    • 例外を投げる関数の処理
      • 基本的には多くの例外はドメイン設計の一部ではなく、トップレベル以外では補足する必要はない
      • では、もし例外をドメインの一部として扱うとしたら?
        • 例: リモートサービスからのタイムアウトをトラップしてRemoteServiceErrorに変換したい
      • 例外を発生させる関数をResultを返す関数に変換するアダプターブロックを作成する
      •   // エラーを起こしたサービスを追跡するためのServiceInfoを作成する
          type ServiceInfo = {
            Name : string
            Endpoint : Url
          }
          
          // エラー型を定義する
          type RemoteServiceError = {
            Service : ServiceInfo
            Exception : System.Exception
          }
          
          // 例外を投げるサービスをResultを返すサービスに変換する
          // アダプターブロックを作成する(ドメインに関連する例外のみキャッチする)
          let serviceExceptionAdapter serviceInfo serviceFn x = 
            try
              Ok (serviceFn x)
            with
            | :? TimeoutException as ex ->
              Error { Service = serviceInfo; Exception = ex }
            | :? AuthorizationException as ex ->
              Error { Service = serviceInfo; Exception = ex }
          
          // 実際のサービスの例
          let serviceInfo = {
            Name = "AddressCheckingService"
            Endpoint = ...
          }
          
          // 例外を発生させるサービス
          let checkAddressExists address =
            ...
          
          // Resultを返すサービス(Resultを返すため"R"をつけている)
          let checkAddressExistsR address =
            // サービスを適合させる
            let adaptedService =
              serviceExceptionAdapter serviceInfo checkAddressExists
            // サービスを呼び出す
            address
            |> adaptedService
            |> Result.mapError RemoteService // PlaceOrderError型
          
          // 新しいサービスのシグチャ
          checkAddressExistsR :
            UnvalidatedAddress -> Result<CheckedAddress, RemoteServiceError>
          
          // パイプラインで使用するためにケースを追加する
          type PlaceOrderError =
            | Validation of ValidationError
            | Pricing of PricingError
            | RemoteServiceError of RemoteServiceError // 追加
        
    • 行き止まりの関数の処理
      • 行き止まりまたは打ちっぱなしの関数と呼ばれる
        • 入力を受け取っても出力を返さない関数
        • 何らかの方法でI/Oに書き込んでいる
          • DBへの書き込み、キューへの投稿など
      • 2トラックパイプラインで動作させるためには、さらに別のアダプターブロックが必要
        • パススルー関数が必要
          • 受け取った入力で行き止まりの関数を呼び出してから、元の入力を返す仕組み
        • 行き止まりの関数とパススルー関数の例
            // ('a -> unit) -> ('a -> 'a)
            let tee f x =
              f X
              x
            
            // ('a -> unit) -> (Result<'a, 'error> -> Result<'a, 'error>)
            let adaptDeadEnd f =
              Result.map (tee f)
          
    • コンピュテーション式で暮らしを楽にする
      • 条件分岐やループと組み合わせたり、深くネストしているResult関数を扱ったりする場合にどうするか
      • コンピュテーション式はbindのわずらわしい部分を裏に隠す
        • let!で結果をアンラップして内部の値を取得している
          • 通常の値として渡すことができる
        • mapErrorによって共通の型に持ち上げる
        • まるでResultを使っていないかのようなコードにすることができる
      • コンピュテーション式の使用例
          // コンピュテーション式を使用しない場合
          let placeOrder unvalidatedOrder =
            unvalidatedOrder
            |> validateOrderAdapted
            |> Result.bind priceOrderAdapted
            |> Result.map acknowledgeOrder
            |> Result.map createEvents
          
          // コンピュテーション式を使用する場合
          let placeOrder unvalidatedOrder =
            result {
              let! validatedOrder =
                validateOrder unvalidatedOrder
                |> Result.mapError PlaceOrderError.Validation
              let! pricedOrder =
                priceOrder validatedOrder
                |> Result.mapError PlaceOrderError.Pricing
              let acknowledgmentOption =
                acknowledgeOrder pricedOrder
              let events =
                createEvents pricedOrder acknowledgmentOption
              return events
            }
        
    • コンピュテーション式の合成
      • コンピュテーション式の魅力の1つは合成可能であること
      • コンピュテーション式の合成
          let validateOrder input = result {
            let! validatedOrder = ...
            return validatedOrder
          }
          
          let priceOrder input = result {
            let! pricedOrder = ...
            return pricedOrder
          }
          
          // より大きなresult式で使用できる
          let placeOrder unvalidatedOrder = result {
            let! validatedOrder = validateOrder unvalidatedOrder
            let! pricedOrder = priceOrder validatedOrder
            ...
            return ...
          }
        
    • Resultで注文を検証する
      • ヘルパー関数がResultを返すようになった場合
          // すべてのヘルパー関数がResultを返すようになっても、
          // コンピュテーション式であればプレーンな値として扱うことができる
          let validateOrder : ValidateOrder =
            fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
              result {
                let! orderId =
                  unvalidatedOrder.OrderId
                  |> OrderId.create
                  |> Result.mapError ValidationError
                let! customerInfo =
                  unvalidatedOrder.CustomerInfo
                  |> toCustomerInfo
                let! shippingAddress = ...
                let! billingAddress = ...
                let! lines = ...
          
                let validatedOrder : ValidatedOrder = {
                  OrderId = orderId
                  CustomerInfo = customerInfo
                  ShippingAddress = shippingAddress
                  BillingAddress = billingAddress
                  Lines = lines
                }
                return validatedOrder
              }
        
    • Resultのリストを扱う
      • mapではResultのリストが返ってきてしまう
      • Resultのリストをループして、1つでも失敗があれば結果をエラーとするヘルパー関数を作成する
      • Resultのリストを取り扱う例
          /// Result<item>をResult<list>>に前置する
          let prepend firstR restR =
            match firstR, restR with
            | Ok first, Ok rest -> Ok (first :: rest)
            | Error err1, Ok _ -> Error err1
            | Ok _, Error err2 -> Error err2
            | Error err1, Error _ -> Error err1
          
          let sequence aListOfResults =
            let initialValue = Ok [] // Result内の空のリスト
          
            // リストを逆順でループしながら、
            // 要素を初期値に前置していく
            List.foldBack prepend aListOfResults initialValue
          
          // 試しに使ってみる例
          type IntOrError = Result<int, string>
          
          let listOfSuccesses : IntOrError list = [Ok 1; Ok 2]
          let successResult =
            Result.sequence listOfSuccesses // Ok [1, 2]
          
          let listOfErrors : IntOrError list = [ Error "bad"; Error "terrible"]
          
          let errorResult =
            Result.sequence listOfErrors // Error "bad"
          
          // validatedOrderを再構築する
          let validateOrder : ValidateOrder =
            fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
              result {
                let! orderId = ...
                let! customerInfo = ...
                let! shippingAddress = ...
                let! billingAddress = ...
                let! lines =
                  unvalidatedOrder.Lines
                  |> List.map (toValidatedOrderLine checkProductCodeExists)
                  |> Result.sequence // Resultのリストを単一のResultに変換する(mapとsequenceはtraverseでまとめられる)
          
                let validatedOrder : ValidatedOrder = {
                  OrderId = orderId
                  CustomerInfo = customerInfo
                  ShippingAddress = shippingAddress
                  BillingAddress = billingAddress
                  Lines = lines
                }
                return validatedOrder
              }
          
          // ワークフローを再構築する
          let placeOrder : PlaceOrder =
            fun unvalidatedOrder ->
              result {
                let! validatedOrder =
                  validateOrder checkProductCodeExists checkAddressExists unvalidatedOrder
                  |> Result.mapError PlaceOrderError.Validation
                let! pricedOrder =
                  priceOrder getProductPrice validatedOrder
                  |> Result.mapError PlaceOrderError.Pricing
                let acknowledgmentOption = ...
                let events = ...
                return events
              }
        

モナドなど

  • モナドなど
    • モナドとは
      • プログラミングパターンの1種で、モナディックな関数を直列につなげることができるもの
      • モナディックとは
        • 通常の値を受け取り、ある拡張がされた値を返す関数
        • Result型でラップされたものを返すスイッチ関数など
      • 技術的なモナドの定義
        • データ構造
        • 関連するいくつかの関数
          • return(pure): 通常の値をモナディック型に変える関数。ResultだとOkコンストラクタにあたる
          • bind(flatMap): モナディック関数を連鎖させられる関数
        • 関数の動作ルール
          • 実装が正しく、おかしなことがないか保証するためのシンプルな指針
    • アプリカティブを使って並列に合成する
      • モナディックな値を並列に組み合わせることができる
        • 検証の場合、最初のエラーだけでなくすべてのエラーを結合するために使う
    • 専門用語
      • エラー処理では、bind関数はResultを生成する関数を2トラックの関数に変換して、直列に繋げることを可能とする
      • map関数は1トラック関数を2トラック関数に変換する
      • モナディックな合成アプローチとは、bindを用いて関数を直列に連結すること
      • アプリカティブな合成アプローチとは、結果を並列に組み合わせること

非同期エフェクトの追加

  • 非同期エフェクトの追加
    • AsyncResult型に合わせて、asyncResultコンピュテーション式を定義する
    • asyncResultコンピュテーション式を使用した例
        let validateOrder : ValidateOrder =
          fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
            asyncResult {
              let! orderId =
                unvalidatedOrder.OrderId
                |> OrderId.create
                |> Result.mapError ValidationError
                |> AsyncResult.ofResult // ResultをAsyncResultに持ち上げる
              let! customerInfo =
                unvalidatedOrder.CustomerInfo
                |> toCustomerInfo
                |> AsyncResult.ofResult
              let! checkedShippingAddress =
                unvalidatedOrder.ShippingAddress
                |> toCheckedAddress checkAddressExists
              let! shippingAddress =
                checkedShippingAddress
                |> toAddress
                |> AsyncResult.ofResult
              let! billingAddress = ...
              let! lines = 
                unvalidatedOrder.Lines
                |> List.map (toValidatedOrderLine checkProductCodeExists)
                |> Result.sequence // Resultのリストを単一のResultに変換する
                |> AsyncResult.ofResult
              let validatedOrder : ValidatedOrder = {
                OrderId = orderId
                CustomerInfo = customerInfo
                ShippingAddress = shippingAddress
                BillingAddress = billingAddress
                Lines = lines
              }
              return validatedOrder
            }
        
        type CheckAddressExists =
          UnvalidatedAddress -> AsyncResult<CheckedAddress, AddressValidationError>
        
        /// checkAddressExistsを呼び出し、エラーをValidationErrorに変換する
        let toCheckedAddress (checkAddress: CheckAddressExists) address =
          address
          |> checkAddress
          |> AsyncResult.mapError (fun addrError ->
            match addrError with
            | AddressNotFound -> ValidationError "Address not found"
            | InvalidFormat -> ValidationError "Address has bad format"
            )
        
        let placeOrder : PlaceOrder =
          fun unvalidatedOrder ->
            asyncResult {
              let! validatedOrder =
                validateOrder checkProductExists checkAddressExists unvalidatedOrder 
                |> AsyncResult.mapError PlaceOrderError.Validation
              let! pricedOrder =
                priceOrder getProductPrice validatedOrder
                |> AsyncResult.ofResult
                |> AsyncResult.mapError PlaceOrderError.Pricing
              let acknowledgmentOption = ...
              let events = ...
              return events
            }
      

まとめ

  • まとめ
    • 型変換は面倒だが、これによってすべてのパイプラインコンポーネントが問題なく連携して動作するという信頼感が得られる

03へ続く

https://zenn.dev/shimpei_takeda/articles/34a2dcc00053f0

Discussion