UGA Boxxx

つぶやきの延長のつもりで、知ったこと思ったこと書いてます

【DMMF】モデルを実装する

『Domain Modeling Made Functional』を読んで、モデルを実装に関する個人的メモ

関数について

他の関数を入力または出力したり、関数をパラメーターとして受け取る関数は、高階関数と呼ばれる

カリー化

関数の出力を関数にできるのであれば、複数パラメータを入力に持つ関数を1 パラメータ関数に変換できる

この方法をカリー化という

たとえば、以下のような 2 つのパラメーターの足し合わせるadd関数は

const add = (x, y) => x + y

以下のようにすることで 1 パラメーター関数に変換できる

const adderGenerator = (x) => (y) => x + y

全域関数

全ての入力値に対して対応する出力値が一意に決まる関数

例えば、12を入力値(整数)で割り算した結果を返す関数を考えた時、入力値0の時はどうなるのか

​let​ twelveDividedBy n =
  ​match​ n ​with​
    | 6 -> 2
    | 5 -> 2
    | 4 -> 3
    | 3 -> 4
    | 2 -> 6
​    | 1 -> 12
​    | 0 -> ???

出力に制限がないならばエラーとなるだろうが、この関数を以下のように定義していた場合は、int型ではなくなるため嘘になる

12DividedBy : int -> int

この場合、この関数は全域性がないので全域関数とは呼ばない

DMMFではできる限り全域関数になるようにしたいので、この関数の入力値を「0以外の整数」に制限するようにする

参考:Totality - kawasima

関数合成による情報の隠蔽

最初の関数の出力の型が、2番目の関数の入力の型と一致するとき関数を結合させることができる

これを関数合成という

この種の構成の重要なことは「情報の隠蔽」で、関数がより小さな関数で構成されているかどうか、またより小さな関数が何を操作するのかを知ることはできない

この機能を使うユーザーに、バナナがどこに消えたのか?そもそもバナナの存在していたことを知らなくすることができる

パイピング

関数合成を実装する場合、「パイピング」と呼ばれるアプローチを使用する

最初の値を入力値にして、最初の関数の出力が、2番目の関数の入力になるときこのように表現する

const パイピングした関数 = (入力値) =>
入力値
|> 最初の関数
|> 2番目の関数

この関数のユーザーは以下の機能と見なすことができる

パイピングした関数 = 入力値 -> 2番目の関数の出力値

関数からアプリケーション全体を構築する

小さい関数を組み合わせてサービスを構築する

最終的にはドメインイベントを反映したワークフローになる

ワークフローは、以下のような各ステップが「パイプ」として設計された一連のドキュメント変換である

注文ワークフロー

  1. 「未精査注文」から始めて「精査済注文」に変換し、検証が失敗した場合はエラーを返す(UnvalidatedOrder → Result<ValidatedOrder, ValidateError>)
  2. 「精査済注文」に追加情報を追加して「価格付き注文」に変換する(ValidatedOrder -> PricedOrder)
  3. 「価格付き注文」から承諾書を作成して送信する(PricedOrder -> PricedOrder)
​const​ 注文する = (未精査注文) =>
  未精査注文
​  |> 未精査注文を精査する
 ​ |> 精査済注文に価格を追加する
​  |> 承諾書を作成する

ただ、このとき以下のような問題がよく発生する

  • 未精査注文を精査するときに外部の他の関数を利用したい
    →「依存関係」と呼ばれるパイプラインの一部ではないが実装には必要な追加のパラメーターがある場合
  • 処理1の出力はResult<ValidatedOrder, ValidateError>だが処理2の入力ValidatedOrderなので関数合成できない
    →上流の出力と下流の入力が合っていない場合

依存注入

「依存関係」と呼ばれるパイプラインの一部ではないが実装には必要な追加のパラメーターがある場合は関数の引数として渡す

例えば未精査注文を精査する関数は、外部の関数(例えばcheckAddressExistsというメアドが使えるものかを調べる関数)を内部で使うとすると、checkAddressExistsが「依存関係」にあたる

この時、checkAddressExistsはパイプラインの一部ではないので、カリー化して未精査注文とは別のパラメータにする

具体的には以下のようになる

const validateOrder = (checkAddressExists) => (unvalidatedOrder) => 未精査注文をcheckAddressExistsを使って精査する処理

checkAddressExistsvalidateOrderの呼び出し元で依存注入される

これにより、checkAddressExistsを擬似的なものに置き換え可能なのでvalidateOrderのテストを単体で行うメリットもある

関数アダプター

上流の出力と下流の入力が合っていない場合は、関数アダプターを用意する

通常の関数は1つの線路のイメージ

Result出力を持つ関数は、次のように 2 つに分かれた線路のイメージ

これを繋げるバイパスのような関数(関数アダプター)を用意する

この繋げる処理自体はバインド(bind)やフラップマップ(flapMap)といったりする

実装は以下のようになる

const bind =
  <T, R, E>(fn: (data: T) => Promise<Result<R, E>>) =>
  (input: Promise<Result<T, E>>) => {
    return input.then((result) => {
      if (result.error) {
        return result;
      } else {
        return fn(result.data);
      }
    });
  };

ここまでの注文ワークフローを実装すると、typescriptでは以下のような実装になる

​const​ 注文する = (unvalidatedOrder) => 
pipe(
   unvalidatedOrder,
   validateOrder(checkAddressExists),
   bind(精査済注文に価格を追加する),
   bind(承諾書を作成する)
)

bindの使用以降は常に2レーンになるため、以降はbindが必要になる

シリアライズ

ワークフローの入力はコマンドから取得される

コマンドは境界のあるコンテキストの外側にあるインフラストラクチャから送信される

インフラストラクチャは特定のドメインを理解していないため、ドメインモデルの型をインフラストラクチャが理解できるもの (JSONXML、protobuf などのバイナリ形式など) に変換する必要がある

永続性

すべての関数が「純粋」であることが理想であり、そうすることで関数の推論とテストが容易になる

ただ、外部との読み取りまたは外部への書き込みを行う関数は純粋であることができないため、ワークフローを設計するときはワークフロー内にあらゆる種類の I/O または永続化関連のロジックが混合することを回避するべきである

もしワークフローの中でどうしても出てきてしまう場合は、純粋なビジネスロジックだけの関数を抜き出し、それを分離させた上で I/O が許可される境界コンテキストの境界でコマンドハンドラーの一部として使用するようにする

純粋な関数を中心を持ち、エッジに I/O の関数を置いたサンドイッチ構造にする

「純粋な」コードの途中で、データベースからの読み取りに基づいて決定を下す必要がある場合は純粋な関数をそのまま保持し、以下のように不純な I/O 関数の間にそれらを挟む

ただ、I/O とロジックが混在しすぎのはよくないので、ワークフローを細かくして単純なサンドイッチにすることを推奨する

コマンドとクエリの分離(CQS)

ストレージ システムもある種の不変オブジェクトとして考える

つまり、ストレージ システム内のデータを変更するたびに、データはそれ自体の新しいバージョンに変換されると考える

type InsertData = DataStoreState -> Data -> NewDataStoreState

CRUDを並べると以下のようになる

type InsertData = DataStoreState -> Data -> NewDataStoreState
​type ReadData = DataStoreState -> Query -> Data
type UpdateData = DataStoreState -> Data -> NewDataStoreState
type DeleteData = DataStoreState -> Key -> NewDataStoreState

この時、ReadDataだけ状態を変化しない

コマンドとクエリの分離は、このReadとWrite系の区別に基づいた設計原則である

CQS 原則を関数型プログラミングに適用すると、次のような制約になる

  • データを返す関数には副作用があってはならない
  • 副作用 (状態の更新) がある関数はデータを返してはならない(voidを返す関数である必要がある)

これは今まで設計してきた方針と変わらない

もう少しデータベース寄りの関数シグネチャにすると

  • 入力側では、DataStoreState を DbConnection などのデータストアへの何らかのハンドラーに置き換える
  • データストアは変更要求を受け付けて更新されるが、実際は新しい状態を返すわけではないので、出力はvoidになる
type InsertData = DbConnection -> Data -> void

DbConnectionは依存関係にあたるので、関数シグネチャから削除すると以下になる

type InsertData = Data -> void

そして、実際はエラーも考慮する必要があるので最終的にInsertDataは以下のように変更する

type DbResult<T> = AsyncResult<T, DbError>
type InsertData = Data -> DbResult<void>

一方、ReadDataは以下になる

type ReadData = Query -> DbResult<Data>

この時、上記のDataにあたる型を例えばCustomer型だとすると、 InsertDataとReadDataで共通した型を扱うように見えるがそうではなく、書き込む時のCustomer(WriteModelのCustomerと呼ぶ)と読み取る時のCustomer(ReadModelのCustomerと呼ぶ)で異なるものとする

type SaveCustomer = WriteModel.Customer -> DbResult<void>
type LoadCustomer = CustomerId -> DbResult<ReadModel.Customer>

このCQRSのアプローチは、イベントソーシングに関連付けられて使用される(イベントソーシングについては別のブログでまとめる)

複数のドメインからのデータの操作

レポート作成システムやビジネス分析システムは、複数のコンテキストのデータにアクセスする必要があるが、他のコンテキストが所有するDBへのアクセスは原則行ってはならない

ではどうするか?というと、「レポート」または「ビジネス分析」を別のドメインとして扱い、他のコンテキストが所有するデータをコピーするようにして各システムの問題領域に対応できるようにする

取得する方法は色々あるが、例えば

  • 他のシステムによって発行されたイベントをサブスクライブして、トリガーされた時に対応するレコードを独自のデータストアに挿入する
  • 従来のETLプロセスを利用して必要なデータの形にして取得する

など

「ビジネス分析」ドメイン内では、正式なドメインモデルはほとんど必要ない