関数型ドメインモデリング』勉強会 第9章 実装:パイプラインの合成(前編)

52
関数型ドメインモデリング』勉強会 第9章 実装:パイプラインの合成(前編)

ここまでは、型だけを用いてドメインモデリングを行ってきた。ここでは、これまで行ってきた設計を、関数型の原則を使って実装していく。

これまでの設計を見ると、ワークフローは一連のドキュメントの変換、すなわちパイプラインと考えることができる。各ステップの関数をつなぎ合わせたコードは、開発者でなくても理解しやすいものになる。

パイプラインの各ステップの関数は、ステートレスで副作用のないようにする必要がある。

パイプラインの合成では、パイプラインの出力型と入力シグニチャのミスマッチによっていろいろ問題が発生する。
本章では、パイプラインの入力出データではないが処理には必要な追加のパラメータ(依存関係)がある場合に、それを外部からの入力として受け取り、「依存関係の注入」と同等のことを関数型プログラミングで実現する。

9.1 単純型を扱う

単純型とは、単一ケース共用体

type CustomerId =
| CustomerId of int

一行で、

type CustomerId = CustomerId of int

単純型がラップしている内部値を取得するには、パターンマッチを使う必要がある。

let value (CustomerId id) = // ここでパターンマッチにより内部値が取得される
  id  // 内部値を返す

単純な値の完全性

完全性とは、型に属するデータが、常にビジネスルールに従っていること。
例:「重量は 0 以上 100kg以下」

プリミティブな値をラップし、完全性を保証するために、少なくとも2つの関数が必要になる。

  • string や int などのプリミティブな値から、完全性が確認できる場合のみラップ型を構築する create 関数
  • パターンマッチによって内部のプリミティブな値を抽出する value 関数

これらのヘルパー関数は型を定義するファイルの中に、サブモジュールとして定義する。

本章では、create関数でエラーが発生した場合は、Result のような副作用を選択肢として返す選択型を使わずに、例外を使用している。

スマートコンストラクタ:
コンストラクタをプライベートにして別の関数を用意し、有効な値は作成するけれども無効な値は拒否してエラーを返すようにする。

9.2 関数の型から実装を導く

第5章の「5.5 関数によるワークフローのモデリング」では、関数の型(シグニチャ)を定義することでワークフローを表現していた。
たとえば、未検証の注文書を検証するワークフローは次のような型になる。

type ValidateOrder = UnvalidatedOrder -> ValidatedOrder

型を使わず、直接に次のような関数定義も可能だが、この場合、引数の checkProductCodeExists などの型は、validateOrder の本体を見て初めて分かることになる。

let validateOrder
  checkProductCodeExists // 依存関係
  checkAddressExists // 依存関係
  unvalidatedOrder = // 入力
  ...

ここでは、あらかじめ依存関係となる関数の型を定義しておき、それを利用して ValidateOrder 関数の型(シグネチャ)を定義し、関数本体をラムダ式で定義するやり方を提示する。

まず、依存関係の関数の型定義。

type CheckProductCodeExists = UnvalidatedAddress -> CheckedAddress
type CheckAddressExists = ...

ValidateOrder 関数のシグネチャ。

type ValidateOrder =
  CheckProductCodeExists -> CheckAddressExists -> UnvalidatedOrder -> ValidatedOrder

ラムダ式(fun)を用いた関数本体の定義。

let validateOrder : ValidateOrder =
  fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
    // ^依存関係              ^依存関係           ^入力
      ...

この手法の利点は、すべてのパラメータと戻り値の型が関数の型によって決定していること。

初めに挙げた直接に関数を定義する方法だと、関数本体の中で checkProductCodeExists などに誤った引数を与えたとしても、コンパイラはそれが正しい引数だとして型推論をしてしまい、別の場所で(本当は正しいのに)コンパイルエラーが発生して混乱することになる。

要するに、型推論に頼りすぎると痛い目を見ますよ、ということ。

9.3 検証ステップの実装

プリミティブなフィールドを持つ未検証の注文を、完全に検証済みの適切なドメインオブジェクトに変換する。

ドメイン型
境界づけられたコンテキスト内で定義されるオブジェクト(ドメインオブジェクト)を表わす型のこと

下記の「未検証の注文」は、ドメイン外から送られてくる。

// 未検証の注文
type UnvalidatedOrder =
  UnvalidatedCustomerInfo               // 未検証の顧客情報
  AND UnvalidatedShippingAddress        // 未検証の配送先住所
  AND UnvalidatedBillingAddress         // 未検証の請求先住所
  AND ...

これをドメイン内のオブジェクトである「検証済み注文」に変換する。

// 検証済みの注文
type ValidatedOrder =
  OrderId
  AND CustomerInfo
  AND ShippingAddress
  AND BillingAddress
  AND ...

単純な型は create関数を呼ぶことで値を生成する

顧客情報の各種プロパティなどの単純な型は、create 関数を呼ぶことで生成できる。(「9.1 単純型を使う」)

複数な型の変換にはヘルパー関数を用意する

未検証な CustomerInfo を検証済みの CustomerInfo に変換したり、未検証の ShippingAddress を検証済みの Address に変換するには、ヘルパー関数 toCustomerInfotoAddress を用意する。

単一ケース共用体型の内部要素の取得にパターンマッチを使う

// パターンマッチを使用して内部値を抽出する
let (CheckedAddress checkedAddress) = checkedAddress

ChackedAddress の定義 (7.4.1 検証のステップ)

type CheckedAddress = CheckedAddress of UnvalidatedAddress

単一ケース共用体の構築と分解(5.3.1 単一ケース共用体の利用)

多引数の関数は、部分適用によって1引数関数にしてからパイピングを適用する

let toAddress (checkAddressExists:CheckAddressExists) unvalidatedAddress =
  ...

このような2引数関数をパイプラインで使う場合は、toAddress に第1引数となる関数を与えて部分適用を行い、その結果の1引数関数を使用する。

let shippingAddress =
  unvalidatedOrder.ShippingAddress
  |> toAddress checkAddressExists  // 部分適用

選択型を返す

// 注文数量関連
type UnitQuantity = UnitQuantity of int
type KilogramQuantity = KilogramQuantity of decimal
type OrderQuantity =
  | Unit of UnitQuantity
  | Kilogram of KilogramQuantity

Widget と Gizmo では数量の型が異なる。前者は UnitQuantity であり、後者は KilogramQuantity。これらを統合的に扱うために、それぞれ OrderQuantity.UnitOrderQuantity.Kilogram によって同一の型である OrderQuantity に変換している。

出力と入力の型が合わない場合は、関数アダプターを作成する

関数アダプターは、関数を引数にとり、それをラップして別の型を返すような関数のこと。
本文では、bool を返す1引数の述語関数をラップして、述語がtrueを返すなら引数をそのまま出力し、falseを返した場合は例外を発生させるような関数アダプターを作成している。
この関数アダプターは、1引数述語であれば引数の型は何でもよいということで、ジェネリックな関数になっている。

おまけ: 0引数の関数 = 定数

let foo = 1
これは 0引数の関数とみなすこともできる。引数が無いので、呼び出した時は常に同じ値を返す。