関数型ドメインモデリング勉強会 第7章 パイプラインによるワークフローのモデリング

  • 2025.01.29
78
関数型ドメインモデリング勉強会 第7章 パイプラインによるワークフローのモデリング

7章全般で見ると結構ボリューミーだが、特に重要で他の言語でも応用が見込めるのは状態を型で表現する事と依存関係の表現なので、そこに絞ってメモしていく。


ここまでの章ではF#の型システムを用いたドメインモデルの表現や、その整合性について学んできたが、この章ではそれらをワークフローの表現に適用していく。ここでワークフローという言葉が意味するところはビジネスプロセスの一部を詳細に記述したものであり、例えば注文確定のワークフローであれば以下の疑似言語で表現される。

workflow "Place Order"
    input UnvalidateOrder
    output (on success):
        OrderAcknowledgementSent
        And OrderPlaced (to send to shipping)
        And BillableOrderPlaced (to send to billing)
    output (on error):
        ValidationError

    // 注文の検証
    do ValidateOrder
    if order is invalid then:
        return with ValidationError
    // 注文の価格計算
    do PriceOrder

    // 注文の承認
    do AcknowledgeOrder

    // イベントの生成
    create and return the events

この疑似言語で表現されたワークフローは、注文の検証や価格計算など、細かいサブステップに分割できることは明らかであり、この一連の処理は

  • 未検証の注文を検証済みの注文に変換する、あるいはエラーを送出する
  • 検証済みの注文を価格計算済みの注文に変換する
  • 価格注文済みの注文を承認済みの注文に変換する(副作用として注文確認を顧客に送る)
  • 承認済みの注文を「注文確認を送った」「注文が確定した」「請求可能な注文が確定した」といったイベントに変換、送信する

といった一連のデータ変換のプロセスとして考える事が可能である。このように一連の小さな処理をパイプとして繋ぎ、最終的に大きなパイプラインにするスタイルを変換指向プログラミングなどと言うようだ。そして関数型言語はこのようなスタイルを非常に取りやすい傾向にある。この理由は基本的に個々のステップはステートレスであり、独立してテストし、理解できる。

なお上記のステップのうちAcknowledgeOrderでの「注文確認を顧客に送る」という副作用をどのように型システムとして表現するか、そしてこれまでの章で扱ったようにどうやってパイプラインの端に追いやるかといった事が気になる所でもある。

7.1 ワークフローの入力

ワークフローへの入力は常にドメインオブジェクト、というルールを設定する。このドメインオブジェクトは既にDTOからデシリアライズされたものとする。今回扱う例での入力は未検証の注文としてモデル化されたドメインオブジェクトである。

type UnvalidatedOrder = {
  OrderId: string
  CustomerInfo : UnvalidatedCustomerInfo
  ...
}

そしてこれも前の章で扱った事だが、ワークフローはコマンドによって開始されるため、ある意味でコマンドこそがワークフローの真の入力であると考えられる。このワークフローでいうと、このコマンドをPlaceOrder(注文を確定する)と呼ぶ事にする。このコマンドは、ワークフローを実行するための情報と、ワークフローがいつ、誰によって実行されたかといったメタ情報を含んだものである必要がある。そのため、簡単には以下のような定義が考えられる。

type PlaceOrder = {
    OrderForm: UnlicatedOrder
    Timestamp: DateTime
    UserId: string
}

しかし実際のシステムを考えると、他にもワークフローは複数存在する事が考えられる。一方でどのワークフローも同じようにコマンドが起点となって起動されると考えると、コマンドの定義はジェネリクスを用いて以下のように記述した方が良い。

type Command<`data> = {
    Data: `data
    Timestamp: DateTime
    UserId: string
}

type PlaceOrder = Command<UnvalidatedOrder>

こうする事で、「注文を確定する」コマンドは、総称型であるCommandにUnvalidatedOrderを適用したものとして定義可能になる。

そしてこの時に受注コンテキストに他のワークフロー、例えば注文変更ワークフローや注文キャンセルワークフローが存在する場合、それらに対応したコマンドを定義し、それらすべてを含んだOR型で「受注コマンド」というものを考える事が出来る。この場合、受注コマンド = 注文を確定するコマンド | 注文を変更するコマンド | 注文をキャンセルするコマンド となる。そしてこれらをワークフローに振り分けるコマンドハンドラー(ディスパッチャーと呼んだ方が直観的か)をワークフロー起動前のステップに追加する事で、外部から見た受注コンテキストのワークフローの集合は「受注コマンド(実際にはシリアライズされたDTO)を入力すると、それぞれのワークフローに応じたイベントが発生する」という形で見る事が出来る。

この辺はReact HooksでActionやReducerを作った事がある人なら結構馴染みがあるかもしれない。この場合、ワークフローというのは最終的に実行される関数であり、コマンドというのはAction型である。Reducerがコマンドハンドラーとして適切な関数をアクションから選択して、関数が新たなアプリケーションの状態を返す、という流れはこのワークフローのモデリングと思想的に通じるものがあるだろう。

状態の集合による注文のモデリング

実際の業務システムでは、「注文」というのは静的な書類ではなく、その状態を様々に変えるものである。例えば

  • 未処理の注文書(まだ何もしていない)
    • 未検証の注文(注文であることは分かっているが未検証)
      • 検証済みの注文(注文内容は検証した)
        • 価格計算済みの注文(価格計算まで完了した)
        • 無効な注文(価格計算した結果無効となった)
    • 未検証の見積もり(見積もり依頼である事は分かっているが未検証)

このような状態をモデル化する際に、ついついやってしまいがちなのがそれぞれの検証状態に対応したフラグを持たせることだが、これは色々とマズイ。仮に「注文フラグ」「見積もりフラグ」「注文検証済みフラグ」「見積もり検証済みフラグ」のように持たせてしまうと、それらのフラグの相関チェックがどんどんややこしい事になってくる。

このような場合には、それぞれの状態について新しい型を作ることが有効とされている。OOPにおけるStateパターンと似ているように見えるが、Stateパターンと選択型による状態の列挙には大きな違いがある。OOPのStateパターンは大元のインターフェースに共通の振る舞いを宣言し、それらについて個別のState型で実装を与えるのに対して、選択型を用いたアプローチは各状態に対して対応する振る舞いをパターンマッチで実行することで、状態の遷移と処理を安全に行う事を意図している。

OOPにおけるStateパターンは凝集度が下がる傾向にあるように思うので、状態遷移を表現するという意味では選択型を用いた手法の方が好ましく思える。また、Stateパターンは各ステートクラスの大元であるインターフェースにその状態で可能な操作を列挙する都合上、本質的に特定のステートでのみ可能な操作なども列挙する必要があり、近年のプログラミング言語では多少なりとも緩和されているとはいえ、やはり収まりが悪く感じる。

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

一連のワークフローのうち、例えば未検証の注文を検証済みの注文に変換する過程では様々なチェック処理が必要になる。例えば注文の製品コードが存在するか、住所が正しいか、などである。この検証のステップは過去の章において以下のように文書化された。

substep "ValidateOrder" = 
    input: UnvalidatedOrder
    output: ValidatedOrder OR ValidationError
    dependencies: CheckProductCodeExists, CheckAddressExists

こうしてみると、注文の検証という処理は「製品コードの存在チェック」「住所の存在チェック」に依存していると考えられる。この時、製品コードの存在チェック処理は製品コードの有無に対して真偽値を返す関数として、以下のように型付けできる。

type CheckProductCodeExists = ProductCode -> bool

それに対して、住所の存在チェックについては、ここでは何らかのリモートアドレスチェックサービス呼び出しを行うものとして、AsyncResult型(TypeScriptのPromise+Result型と思ってほしい)を返すものとして定義してみる。

// CheckedAddress, AddressValidationErrorなどは別途適当に手議されているものとする
type CheckAddressExists = UnvalidatedAddress -> AsyncResult<CheckedAddress, AddressValidationError>

ValidateOrderはこれらに依存しているので、以下のようなシグニチャになる。

type ValidateOrder =
    CheckProductCodeExists // 依存した処理の注入
    -> CheckAddressExcists // 依存した処理の注入
    -> UnvalidatedOrder // 検証対象の注文
    -> AsyncResult<ValidatedOrder, ValidationError>

引数の順序については、依存した処理を部分適用で1つずつ与えていくためにこうしているようだ。勿論、依存関係を全て持ったAND型のtypeを定義して、そこにまとめて2引数の関数にするといった方法も考えられるだろう。

最後の出力がAsyncResultになっている事に注目して欲しい。非同期処理の結果であるAsyncや、失敗する可能性を示すResult型は、それを使うワークフロー全体に波及する。これはTypeScriptのasyncの伝搬や、Javaのチェック例外と同様のものだと考えると分かりやすい。