この記事は、LIFULL Advent Calendar 2024 21日目の記事になります。
はじめに
LIFULLでは、技術負債解消のためにレガシーなコンポーネントをいわゆるCleanArchitecture(以降CA)に置き換えるという取り組みをやっています。
- 内製ソフトウェアアーキテクチャでレガシーシステムを刷新し技術的負債を削減するまでにやったこと
- クリーンアーキテクチャで構築したプロダクトが2年経過してみて現状どうなっているかを紹介
- 新卒エンジニアがリファクタを突貫したClean Architectureプロジェクトの舞台裏
CAはいくつかのレイヤに別れていますが、LIFULLではCAのEnitityをドメイン駆動設計(DDD)で言うところのドメインモデルで実装しています。
この記事は、前に読んでいた関数型ドメインモデリングの「型によるドメインモデリング」がこの実装に応用できるでは?と思い調査したものになります。
また、この本では言語にF#を採用していますが、LIFULLではCAの実装にTypeScriptを使うことが多いため、TypeScriptで実装し直すことも一つのモチベーションになりました。
『関数型ドメインモデリング』ってどんな本?
関数型ドメインモデリングは、その名の通り関数型言語でどのようにドメイン駆動開発をやっていくか?について開設した本です。
ドメイン駆動開発はオブジェクト指向言語の文脈で語られることが多いですが、本書では静的型付け関数型言語が持っている「直和型」や「Result型」、「関数合成」といった関数型特有の機能をうまく活用することで、型安全に可読性高く、ドメイン駆動設計を実装する方法について解説しています。
また、DDDそのものの解説もとても充実しており、DDDの前提知識がなくても読むことができます。
この本を読んで初めてDDDがわかった、という評価も見かけるほどわかりやすいので、DDDの1冊目としてもとても良い本だと思います。
TypeScriptでのドメインモデルの書き方について
TypeScriptで、物件のドメインオブジェクトを実装してみます。
あくまでサンプルのコードだという点にご留意ください。
export class bukken {
private constructor(
private readonly id: number,
private readonly name: string,
private orderFlg: boolean,
private orderDate: Date | undefined
) {}
static create(
id: number,
name: string,
): bukken {
if (id != 0 || name == "") {
throw new Error("Invalid parameter");
}
return new bukken(id, name, false, undefined);
}
getID(): number {
return this.id;
}
getName(): string {
return this.name;
}
isOrdered(): boolean {
return this.orderFlg;
}
getOrderDate(): Date | undefined {
return this.orderDate;
}
order(): void {
this.orderFlg = true;
this.orderDate = new Date();
}
cancelOrder(): void {
this.orderFlg = false;
this.orderDate = undefined;
}
}
ドメインオブジェクトによくある実装パターンとして、完全コンストラクタパターンを採用しています。
これはコンストラクタをprivateにしてオブジェクト生成用のメソッドから呼び出すもので、オブジェクト生成時にvalidationを挟むことができるという利点があります。
static create(
id: number,
name: string,
): bukken {
if (id != 0 || name == "") {
throw new Error("Invalid parameter");
}
return new bukken(id, name, false, undefined);
}
また、このドメインモデルはorder()
とcancelOrder()
によって注文の状態が変化します。
変化の図を書くと以下のような感じになります。
『型によるドメインモデリング』によって書き直してみる
さて、これを『関数型ドメインモデリング』のやり方で書き直してみましょう。
『関数型ドメインモデリング』の『型によるドメインモデリング』では、ドメインモデルの状態遷移を『型の推移』として表現しています。
さらに、ドメインモデル生成時のvalidationについても、validation前の状態をunvalidatedModel
, validation後をvalidatedModel
として表現しています。
validation前の状態をunvalidatedBukken
, 未注文の状態をvalidatedBukken
, 注文済みの状態をvalidatedOrderedBukken
としてモデリングしてみましょう。
これに従うと、このドメインモデルの推移は以下のように表現できます。
これを実装しましょう。
まず、それぞれの状態は以下のように型で表現できます。型として書くことで、orderFlg
が不要になっています。
export type unvalidatedBukken = {
id: number;
name: string;
}
export type validatedBukken = {
id: number;
name: string;
}
export type validatedOrderdBukken = {
id: number;
name: string;
orderDate: Date;
}
次に、状態遷移を関数として書いていきましょう。
まずvalidate()
の状態遷移
は以下のように書けます。
export function validate(data: unvalidatedBukken): validatedBukken {
if (data.id == 0 || data.name == "") {
throw new Error("Invalid parameter");
}
return {
id: data.id,
name: data.name,
};
}
次に、order()
による状態遷移
はこのように書けます。
export function order(data: validatedBukken): validatedOrderdBukken {
return {
id: data.id,
name: data.name,
orderDate: new Date(),
};
}
最後に、cancelOrder()
による状態遷移
はこのように書けます。
export function cancelOrder(data: validatedOrderdBukken): validatedBukken {
return {
id: data.id,
name: data.name,
};
}
全体のコードはこのような感じになります。(簡単のためgetterは除外しています)
export type unvalidatedBukken = {
id: number;
name: string;
}
export function validate(data: unvalidatedBukken): validatedBukken {
if (data.id == 0 || data.name == "") {
throw new Error("Invalid parameter");
}
return {
id: data.id,
name: data.name,
};
}
export type validatedBukken = {
id: number;
name: string;
}
export function order(data: validatedBukken): validatedOrderdBukken {
return {
id: data.id,
name: data.name,
orderDate: new Date(),
};
}
export type validatedOrderdBukken = {
id: number;
name: string;
orderDate: Date;
}
export function cancelOrder(data: validatedOrderdBukken): validatedBukken {
return {
id: data.id,
name: data.name,
};
}
型によるドメインモデリングの利点
実装の仕方を紹介したので、次は型によるドメインモデリングの利点を見てみましょう。
個人的には、以下の3点が利点になると考えています。
利点1. 状態を型として表現することで、デバッグせずに状態遷移を把握することができる
既存のドメインモデリングの場合、注文なしの物件と注文済みの物件はflgによって状態管理されています。
今回の例だと実装が単純なので悩むことがないですが、例えば
if (// 何かの条件) {
this.orderFlg = true;
}
のようにロジックが入り込んでしまうと、今見ている処理が注文済みなのかそうでないかがわからなくなる危険性があります。
また、flgの書き換え処理があちこちに散らばってしまうと、どこで状態が変わってしまうのかわからなくなり、状態遷移の流れを追うことそのものが困難です。
状態遷移を型として表現すれば、状態から状態への遷移は必ずfunctionとして定義されます。
さらに、functionのシグネチャを見れば
export function order(data: validatedBukken): validatedOrderdBukken {
...
となっているので、ソースを読むだけでどの状態からどの状態に遷移しているかを読み取ることができます。
しかも、全てのpropertyをreadonlyにできるため、どこでその値が変更されているかわからないという不安もありません。
そのため、ソースコードレビューもやりやすくなり、改修時の調査も格段に楽になると思われます。
利点2. 型さえ正しく定義すれば、本来あり得ない状態を作らずに済む
今回の例で言うと、既存のドメインモデルでは以下の処理で状態遷移が行われます。
order(): void {
this.orderFlg = true;
this.orderDate = new Date();
}
cancelOrder(): void {
this.orderFlg = false;
this.orderDate = undefined;
}
もし実装を誤って、cancelOrder()
時にorderDate
の初期化を忘れてしまうとどうなるでしょうか?
cancelOrder(): void {
this.orderFlg = false;
// this.orderDate = undefined; を忘れた!!
}
この場合、未注文にも関わらず注文日が設定されているというおかしな状態になってしまいます。
では、本記事のやり方だとどうでしょうか?こちらだと、未注文の物件はorderDate
を持っていません。
export type validatedBukken = {
id: number;
name: string;
}
注文済みのデータはorderDate
を持っています。
export type validatedOrderdBukken = {
id: number;
name: string;
orderDate: Date;
}
そのため、もしcancelOrder()
でorderDate
を消し忘れても、typescriptがエラーを出してくれます!
export function cancelOrder(data: validatedOrderdBukken): validatedBukken {
return {
id: data.id,
name: data.name,
orderDate: Date;
// ↑ 型エラーが発生する
// Object literal may only specify known properties, and 'orderDate' does not exist in type 'validatedBukken'.
};
そのため、たまたま注意力が落ちていたとしても、コンパイルエラーによってミスに気づくことができるのです!
利点3. 型推論で保証できるので、ユニットテストのコード量を減らすことができる
利点2. につながる要素ではありますが、自動でコンパイルエラーを出してくれるため、その分ユニットテストを減らすことができます。
例えば、今回の場合、order()
とcancelOrder()
のテストは以下のように書く必要があります。(どうでも良いですが、本記事のコードはDeno
で書いています。)
Deno.test("bukken.order should set the order flag and date correctly", () => {
const instance = bukken.create(1, "Test Bukken");
instance.order();
assertEquals(instance.isOrdered(), true);
assertEquals(instance.getOrderDate() instanceof Date, true);
});
Deno.test("bukken.cancelOrder should reset the order flag and date correctly", () => {
const instance = bukken.create(1, "Test Bukken");
instance.order();
instance.cancelOrder();
assertEquals(instance.isOrdered(), false);
assertEquals(instance.getOrderDate(), undefined);
});
ですが、型による表現の場合、
export function order(data: validatedBukken): validatedOrderdBukken {
return {
id: data.id,
name: data.name,
orderDate: new Date(),
};
}
export function cancelOrder(data: validatedOrderdBukken): validatedBukken {
return {
id: data.id,
name: data.name,
};
}
となっていて、ロジックがない かつ 型検査で状態遷移の動作保証をしてくれているため
この分のテストを書く必要がなくなるのです(ロジックがあればもちろん書いた方が良いとは思いますが)
型によるドメインモデリングのデメリット
利点ばかり挙げたので、デメリットもいくつか挙げておこうかなと思います。
考えられるデメリットとしては、
- 型定義が増える分、全体のコード量は増える
- 理解するのに慣れが必要
の2つかなと思います。
ただ、1. についてはコード量自体は増えますが、一度に読まないといけないコードの量は減らせるので、認知負荷は逆に下がると考えられます。
あと個人的な感想ですが、validateの型表現はTypeScriptだとメリット薄そうなので、ここは既存の実装でも良いのかなと思いました。
なんにしろ、チームの方針やプロダクト・プロジェクトの性質を見て導入するか判断するのが良いかなと思っています。
まとめ
ざっくりとまとめます。
- ドメインモデリングには、関数型プログラミングの型機能を活用した『型によるドメインモデリング』の手法がある
- 型を使ってドメインモデルの状態遷移を表現することで、可読性・保守性を向上させる上で様々な恩恵を受けることができる
a. 可変な変数を使わないことによる可読性の向上
b. 型として明文化することによる可読性の向上
c. 型検査によるエラーの検出、バグの抑止
d. ユニットテストコードのコード量削減
最後に
ここまで書いてきましたが、この記事は『関数型ドメインモデリング』の一部しか紹介できていません。
他にも、『関数型の特性を活かしたworkflowの実装』や『Result型を使ったエラーハンドリング』、『永続化の方法』など面白い話がたくさん書いてあります。
(この本では、関数型は永続化のためのRepositoryパターンは不要だと言っています。なかなか衝撃的です。)
いつか機会があればそちらも記事にして紹介したいと思います。
良い本なので、ぜひ皆さんも読んでみてください!
参考
- 関数型ドメインモデリング
- 実践ドメイン駆動設計
- 【後編】TypeScript×関数型×DDDで、ユニットテストが激減。実践の全貌とTips【Open Developers Conference 2024 レポート】
- n月刊ラムダノート Vol.4, No.3(2024) #2 型を活用した安全なアプリケーション開発
- Domain Modeling Made Functional - Scott Wlaschin - KanDDDinsky 2019
- 内製ソフトウェアアーキテクチャでレガシーシステムを刷新し技術的負債を削減するまでにやったこと
- クリーンアーキテクチャで構築したプロダクトが2年経過してみて現状どうなっているかを紹介
- 新卒エンジニアがリファクタを突貫したClean Architectureプロジェクトの舞台裏