はじめに
最近関数型 DDD の本を読み,とても参考になることが多かったです.
英語が苦手な私でもそこまで苦労せずに読めましたし,F#のことは全くわからないままでしたが全く問題なかったです.
(今回のサンプルも Rust で書いていますが大きな意図はないです.)
関数型の特徴,DDD,Event,Workflow,依存との付き合い方など今まで私が学んでこなかった領域のことが多く学べました.
今回はその中から型の大切さを記事として残します.
そもそも型とは?
変数や関数などに紐付けられるデータの種類や形式のことで, どのようなデータなのか?どのような引数を取ってどのような返り値を返すのか? などが定義されます.
// xはString型のデータ
let x = String::new();
// MyStruct型にはnameフィールドがある
struct MyStruct {
name:String
}
// Fは引数を取らずに何も返さない関数の型
type F = fn()->();
徹底的に型定義すること!
関数型 DDD の本ではドメインに合わせてとにかく 型定義 することの重要性が書かれていました.
これは単純な文字列や数値型などのプリミティブ型を使わずにドメインに合わせて型を定義せよ!ということです.
例えばオーダーで利用される Id は接頭辞が o-で 10 桁というルールがある文字列だとします.
文字列なので,そのまま文字列型でも間違いではないのですが,あえて OrderId 型というのを独自で定義した方が良いよね,ということです.
// 改善の余地あり
let id = String::from("o-0123456789");
// OK
struct OrderId(String);
let id = OrderId::new("o-0123456789");
徹底した型定義の何が良いのか?
特に私の中で なるほど!これは良い と思った 2 つのトピックを紹介します.
1. 不整合を型レベルで防げる
例えば先ほどの OrderId を文字列型として表現した場合,誤って OrderId とは全く関係のない文字列型に対する処理に OrderId を入れてしまうことができてしまいます.
fn add_respect(name:String)->String{
format!("{}さん",name)
}
let name = String::from("Hoge夫");
let id = String::from("o-0123456789");
// 間違ってidの方を入れてしまっている.ただし問題なく実行される.
let name = add_respect(id);
OrderId 型として定義することでこのようなミスはコンパイルの時点で発見可能になります.
fn add_respect(name:String)->String{
format!("{}さん",name)
}
let name = String::from("Hoge夫");
let id = OrderId::new("o-0123456789");
// 間違ってidの方を入れてしまっているが,コンパイルエラーですぐにミスに気づける!
let name = add_respect(id); // コンパイルエラー
また,OrderId のルールを OrderId を生成する関数に定義し,OrderId の生成をその関数だけができるようにすれば,OrderId の値の一貫性は常に担保されます.
// 内部のStringは公開されてないのでリテラルによる初期化は不可能
pub struct OrderId(String)
impl OrderId {
// 公開されているnew関数からのみOrderIdを生成可能
// 不正な値をチェック.不正であればエラーを返す(Result型はエラーを返すかもしれないことを表現する型)
pub fn new(s:String)->Result<OrderId> {
if Self::validate(&s) {
Ok(OrderId(s))
} else {
Err(...)
}
}
fn validate(s:&str)->bool {
...
}
}
これと似た話で 状態 に対しても型定義することの重要性が書かれていました.
例えばあるサービスのアカウントは作成とともにメールアドレスの検証がされ,検証が成功すればアカウントが有効化されるとします.
あまり考えずにアカウントを型定義すると以下のようになるかと思います.
struct Account {
name: String,
mail_address:String,
// 検証済みかどうか
validated:bool
}
この場合 Account を使う関数は毎回検証済みかどうかを気にする必要があります.
また,検証済みかどうかを確認し忘れると,未検証の Account 情報に対して何かしらの処理を行ってしまう可能性もありセキュリティ的にも危険です.
fn pay(a:Account,money:Money)->Result<()>{
//都度Accountが検証済みか気にしないといけない
if a.validated {
...
Ok(())
} else {
Err(...)
}
}
そこで以下のように検証済みのメールアドレス,有効化前の Account,といったような厳格な型定義をします.
すると Account を利用する関数はそもそも Account の正常性を確認する必要がなくなります.
struct UnvalidateMailAddress(String);
struct UnvalidateAccount {
name:String
mail_address:UnvalidateMailAddress
}
struct ValidatedMailAddress(String);
struct Account {
name:String,
mail_address:ValidatedMailAddress,
}
fn pay(a:Accont,money:Money)->Result<()> {
// UnvalidateなAccountはそもそも入りえない
// 型がしっかりしているため検証不要
...
Ok(())
}
型定義の中に flag などを使わず別の型として定義しよう,ということが書いてあり,とても参考になりました.
このように型定義を厳格にすると, flag 毎のテストや整合性を検証するテスト が不要になり,開発に集中できることもメリットだと書かれており,なるほどな〜と思いました.
本文中には, 「一つ以上の要素が入っていることが確定している NotEmptyList のような型定義もできるよね〜」 とあり,そこまでするんだ!と驚きました.(有効かどうかはコンテキストによると思いますが.)
2. 型定義はドキュメントになりうる
ドメインを意識した型定義をすることで型定義は 立派なドキュメント になりえます.
型定義をドメインエキスパートでもわかるように書くことで,コードを見せながら 認識合わせやフィードバックを得ることが可能になる とのことです.
動くシステムを見せるのが一番質の良いのフィードバックをもらえるとは思いますが, 型レベルで高速なフィードバックを得られることは重要で利がある ことだと感じました.
型定義がどんどん増えていってしまっても,それによりドキュメント性が高まっていくのであれば歓迎すると解釈しました.
逆にいうと,ドキュメント性が高まらないドメインとは違う方向に型定義を進めてしまっていたら,ドメインエキスパートの意見をもとに継続的に型定義を修正する必要はありそうです.
おわりに
今回は関数型 DDD 本で参考になった型定義について記事を書きました. この型定義の考え方は関数型でなくとも役にたつと思っており,最近私の中でも意識しております.
また今回取り上げた型定義以外でもたくさんの有益な情報が関数型 DDD 本にはあったため,またいつかまとめたいと思います.
おまけ
どこまで型定義するのか?
今回の本で紹介されていた面白いテクニックとして,型定義するかどうかの判断はドメインエキスパートに聞いてみて,ドメインエキスパートがしっくりくるかどうかで判断すると良いとありました.
以下は本の中で面白いと思った内容を少し変えたものです.(ユビキタス言語導入の文脈も入っています)
Developer: “Are the quantities integers or floats?”
Domain Export: “Float? Like in water?”
Narration: Ubiquitous language time!
Pro tip: Domain experts do not use programming terms like “float.”
Developer: “What do you call those numbers then?”
Domain Export: “I call them ‘order quantities,’ duh!”
型定義と生成 AI との相性
今流行りの生成 AI は凄まじいです.ただし,もちろん間違ったサジェストもしてきます.
しっかりとした型定義は,ありえない間違いをコンパイラレベルで弾いてくれます.
また,型定義の一つのデメリットである,記述量が増えることに対しても生成 AI がサッとサジェストしてくれるため,人間が型を定義する時間も減っている印象です(少なくとも私は).
型定義と,生成 AI はお互いのデメリットをうまい具合に打ち消しあっていると感じます.