TypeScriptで『関数型ドメインモデリング』をやってみよう

この記事は、[LIFULL Advent Calendar 2024](https://qiita.com/advent-calendar/2024/lifull) 21日目の記事になります。

## はじめに

LIFULLでは、技術負債解消のためにレガシーなコンポーネントをいわゆるCleanArchitecture(以降CA)に置き換えるという取り組みをやっています。

- [内製ソフトウェアアーキテクチャでレガシーシステムを刷新し技術的負債を削減するまでにやったこと](https://www.lifull.blog/entry/2022/02/02/100000)
- [クリーンアーキテクチャで構築したプロダクトが2年経過してみて現状どうなっているかを紹介](https://www.lifull.blog/entry/2023/03/16/170000)
- [新卒エンジニアがリファクタを突貫したClean Architectureプロジェクトの舞台裏](https://www.lifull.blog/entry/2023/04/04/170000)

CAはいくつかのレイヤに別れていますが、LIFULLではCAのEnitityをドメイン駆動設計(DDD)で言うところのドメインモデルで実装しています。

この記事は、前に読んでいた[関数型ドメインモデリング](https://tatsu-zine.com/books/domain-modeling-made-functional)の「型によるドメインモデリング」がこの実装に応用できるでは？と思い調査したものになります。

また、この本では言語にF#を採用していますが、LIFULLではCAの実装にTypeScriptを使うことが多いため、TypeScriptで実装し直すことも一つのモチベーションになりました。

## 『関数型ドメインモデリング』ってどんな本？

関数型ドメインモデリングは、その名の通り関数型言語でどのようにドメイン駆動開発をやっていくか？について解説した本です。

ドメイン駆動開発はオブジェクト指向言語の文脈で語られることが多いですが、本書では静的型付け関数型言語が持っている「直和型」や「Result型」、「関数合成」といった関数型特有の機能をうまく活用することで、型安全に可読性高くDDDを実践する方法を解説しています。

また、DDDそのものの解説もとても充実しており、DDDの前提知識がなくても読むことができます。
この本を読んで初めてDDDがわかった、という評価も見かけるほどわかりやすいので、DDDの1冊目としてもとても良い本だと思います。

## TypeScriptでのドメインモデルの書き方について

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を挟むことができるという利点があります。

```typescript
  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()`によって注文の状態が変化します。
変化の図を書くと以下のような感じになります。

```mermaid
stateDiagram-v2
  [*] --> 注文なし: 初期状態
  注文済み --> 注文なし: cancelOrder()
  注文なし --> 注文済み: order()
```

## 『型によるドメインモデリング』によって書き直してみる

さて、これを『関数型ドメインモデリング』のやり方で書き直してみましょう。
『関数型ドメインモデリング』の『型によるドメインモデリング』では、ドメインモデルの状態遷移を『型の推移』として表現しています。
さらに、ドメインモデル生成時のvalidationについても、validation前の状態を`unvalidatedModel`, validation後を`validatedModel`として表現しています。

validation前の状態を`unvalidatedBukken`, 未注文の状態を`validatedBukken`, 注文済みの状態を`validatedOrderedBukken`としてモデリングしてみましょう。
これに従うと、このドメインモデルの推移は以下のように表現できます。
```mermaid
stateDiagram-v2
  [*] --> unvalidatedBukken(validation前): 初期状態
  unvalidatedBukken(validation前) --> validatedBukken(未注文): validate()
  validatedBukken(未注文) --> validatedOrderedBukken(注文済み): order()
  validatedOrderedBukken(注文済み) --> validatedBukken(未注文): cancelOrder()
```

これを実装しましょう。
まず、それぞれの状態は以下のように型で表現できます。型として書くことで、`orderFlg`が不要になっています。

```typescript
export type unvalidatedBukken = {
  id: number;
  name: string;  
}

export type validatedBukken = {
  id: number;
  name: string;
}

export type validatedOrderdBukken = {
  id: number;
  name: string;
  orderDate: Date;
}
```

次に、状態遷移を関数として書いていきましょう。

まず`validate()`の状態遷移

```mermaid
stateDiagram-v2
  unvalidatedBukken --> validatedBukken: validate()
```

は以下のように書けます。

```typescript
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()`による状態遷移

```mermaid
stateDiagram-v2
  validatedBukken --> validatedOrderedBukken: order()
```

はこのように書けます。

```typescript
export function order(data: validatedBukken): validatedOrderdBukken {
  return {
    id: data.id,
    name: data.name,
    orderDate: new Date(),
  };
}
```

最後に、`cancelOrder()`による状態遷移

```mermaid
stateDiagram-v2
  validatedOrderedBukken --> validatedBukken: cancelOrder()
```

はこのように書けます。

```typescript
export function cancelOrder(data: validatedOrderdBukken): validatedBukken {
  return {
    id: data.id,
    name: data.name,
  };
}
```

全体のコードはこのような感じになります。（簡単のためgetterは除外しています）

```typescript
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によって状態管理されています。
今回の例だと実装が単純なので悩むことがないですが、例えば

```typescript
    if (// 何かの条件) {
      this.orderFlg = true;
    }
```

のようにロジックが入り込んでしまうと、今見ている処理が注文済みなのかそうでないかがわからなくなる危険性があります。
また、flgの書き換え処理があちこちに散らばってしまうと、どこで状態が変わってしまうのかわからなくなり、状態遷移の流れを追うことそのものが困難です。

状態遷移を型として表現すれば、状態から状態への遷移は必ずfunctionとして定義されます。

さらに、functionのシグネチャを見れば
```typescript
export function order(data: validatedBukken): validatedOrderdBukken {
...
```

となっているので、ソースを読むだけでどの状態からどの状態に遷移しているかを読み取ることができます。

しかも、全てのpropertyをreadonlyにできるため、どこでその値が変更されているかわからないという不安もありません。
そのため、ソースコードレビューもやりやすくなり、改修時の調査も格段に楽になると思われます。

#### 利点2. 型さえ正しく定義すれば、本来あり得ない状態を作らずに済む

今回の例で言うと、既存のドメインモデルでは以下の処理で状態遷移が行われます。

```typescript
  order(): void {
    this.orderFlg = true;
    this.orderDate = new Date();
  }

  cancelOrder(): void {
    this.orderFlg = false;
    this.orderDate = undefined;
  }  
```

もし実装を誤って、`cancelOrder()`時に`orderDate`の初期化を忘れてしまうとどうなるでしょうか？

```typescript
  cancelOrder(): void {
    this.orderFlg = false;
    // this.orderDate = undefined; を忘れた！！
  }  
```

この場合、未注文にも関わらず注文日が設定されているというおかしな状態になってしまいます。


では、本記事のやり方だとどうでしょうか？こちらだと、未注文の物件は`orderDate`を持っていません。

```typescript
export type validatedBukken = {
  id: number;
  name: string;
}
```

注文済みのデータは`orderDate`を持っています。

```typescript
export type validatedOrderdBukken = {
  id: number;
  name: string;
  orderDate: Date;
}
```

そのため、もし`cancelOrder()`で`orderDate`を消し忘れても、typescriptがエラーを出してくれます！

```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'.
  };
```

このおかげで、たまたま注意力が落ちていたとしても、コンパイルエラーによってミスに気づくことができるのです！

#### 利点３. 型推論で保証できるので、ユニットテストのコード量を減らすことができる

利点2. につながる要素ではありますが、自動でコンパイルエラーを出してくれるため、その分ユニットテストを減らすことができます。

例えば、今回の場合、`order()`と`cancelOrder()`のテストは以下のように書く必要があります。(どうでも良いですが、本記事のコードは`Deno`で書いています。)

```typescript
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);
});
```

ですが、型による表現の場合、

```typescript
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,
  };
}
```

となっていて、ロジックがない かつ 型検査で状態遷移の動作保証をしてくれているため
この分のテストを書く必要がなくなるのです（ロジックがあればもちろん書いた方が良いとは思いますが）

## 型によるドメインモデリングのデメリット

利点ばかり挙げたので、デメリットもいくつか挙げておこうかなと思います。
考えられるデメリットとしては、

1. 型定義が増える分、全体のコード量は増える
2. 理解するのに慣れが必要

の2つかなと思います。

ただ、1. についてはコード量自体は増えますが、一度に読まないといけないコードの量は減らせるので、認知負荷は逆に下がると考えられます。

あと個人的な感想ですが、validateの型表現はTypeScriptだとメリット薄そうなので、ここは既存の実装でも良いのかなと思いました。

なんにしろ、チームの方針やプロダクト・プロジェクトの性質を見て導入するか判断するのが良いかなと思っています。

## まとめ

ざっくりとまとめます。

1. ドメインモデリングには、関数型プログラミングの型機能を活用した『型によるドメインモデリング』の手法がある
2. 型を使ってドメインモデルの状態遷移を表現することで、可読性・保守性を向上させる上で様々な恩恵を受けることができる
   a. 可変な変数を使わないことによる可読性の向上
   b. 型として明文化することによる可読性の向上
   c. 型検査によるエラーの検出、バグの抑止
   d. ユニットテストコードのコード量削減

## 最後に

ここまで書いてきましたが、この記事は『関数型ドメインモデリング』の一部しか紹介できていません。

他にも、『関数型の特性を活かしたworkflowの実装』や『Result型を使ったエラーハンドリング』、『永続化の方法』など面白い話がたくさん書いてあります。
（この本では、関数型は永続化のためのRepositoryパターンは不要だと言っています。なかなか衝撃的です。）

いつか機会があればそちらも記事にして紹介したいと思います。

良い本なので、ぜひ皆さんも読んでみてください！

## 参考

- [関数型ドメインモデリング](https://tatsu-zine.com/books/domain-modeling-made-functional)
- [実践ドメイン駆動設計](https://www.shoeisha.co.jp/book/detail/9784798131610)
- [【後編】TypeScript×関数型×DDDで、ユニットテストが激減。実践の全貌とTips【Open Developers Conference 2024 レポート】](https://levtech.jp/media/article/column/detail_559/)
- [n月刊ラムダノート Vol.4, No.3(2024) #2 型を活用した安全なアプリケーション開発](https://www.lambdanote.com/products/n-vol-4-no-3-2024)
- [Domain Modeling Made Functional - Scott Wlaschin - KanDDDinsky 2019
](https://www.youtube.com/watch?v=2JB1_e5wZmU&pp=ygUfZG9tYWluIG1vZGVsaW5nIG1hZGUgZnVuY3Rpb25hbA%3D%3D)
- [内製ソフトウェアアーキテクチャでレガシーシステムを刷新し技術的負債を削減するまでにやったこと](https://www.lifull.blog/entry/2022/02/02/100000)
- [クリーンアーキテクチャで構築したプロダクトが2年経過してみて現状どうなっているかを紹介](https://www.lifull.blog/entry/2023/03/16/170000)
- [新卒エンジニアがリファクタを突貫したClean Architectureプロジェクトの舞台裏](https://www.lifull.blog/entry/2023/04/04/170000)