6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ateam LifeDesignAdvent Calendar 2024

Day 3

DDD(ドメイン駆動設計)と関数型ドメインモデリングはカオスなDB設計を救えるか?

Last updated at Posted at 2024-12-02

はじめに

私は TypeScript でDDD(ドメイン駆動設計)をするにはどうしたら良いか思案していました。

DDD の実装例記事を読むと、オブジェクト指向の文脈で書かれたものが多く、もっと TypeScript の良さを活かせる方法はないかと感じているときに出会ったのが『関数型ドメインモデリング』という本でした。

この記事では、『関数型ドメインモデリング』の本から得た、もっと TypeScript を活かした使い方について紹介できればと思います。

想定読者

  • TypeScript でバックエンドを書いてみたい方
  • 関数型ドメインモデリングのメリットについて知りたい方
  • レガシーコードをリファクタリングする手法を調べている方

事例

データベース層に潜む闇

長く運用されたサービスにジョインすると「なんで...こんな設計なんだ?」と思うことはありませんか?

それがデータベース層に起因していると、問題はアプリケーション層に及び、さらに複雑になります。小さな変更が思わぬところに影響して不具合につながったり、機能調査の時間が多大にかかったりします。

カオスなテーブル設計との出会い

例えば、あなたが最初に見た users テーブルがこんな姿だったらどう思うでしょうか?

カラム論理名 データ種類 サンプル値
ユーザーID integer 123
パスワード text gQsxD7dh
名前 text 山田 太郎
アンケート(好きな食べ物) text ラーメン
アンケート(趣味) text 映画
プレゼント金額 integer 3000
プレゼント発送済み boolean false

カラム名は今回の記事内容に関係しないので論理名で記載しています。

テーブルの責務が多すぎる

ツッコミどころは沢山あると思いますが、一つはテーブルの責務が多すぎることが挙げられるでしょう。

関心の分離をしていくと、例えば users テーブルはこのような責務に分けられます。

  1. ユーザー属性
  2. アンケート情報
  3. プレゼント情報

おそらく、当初はユーザー属性だけのシンプルなテーブルだったかもしれません。

テーブルの責務が多すぎることで、様々な処理から users テーブルへの操作が行われることになります。

その結果、小さな機能変更であっても、すべての処理に影響がないか確認する必要がでてきてしまいます。

解決方針

DDDと関数型ドメインモデリングのメリット

本来であれば、users テーブルを分割して責務を明確にするのが良いと思います。

しかし、DB設計のリファクタリングが難しい場合、DDD(ドメイン駆動設計) と関数型ドメインモデリングの手法が活かせるかもしれません。

DDD はドメインエキスパートと話すことで、ビジネスドメインを整理することができます。
その中で発見したドメインモデルをそのままコードに落とし込めるので、DB駆動設計と違い、1モデル1テーブルである必要はありません。

1テーブル 1クラスである必要はない

また、関数型ドメインモデリングでは、ビジネスモデルを型で表現することができます。
ただテーブルを複数のクラス(モデル)に分割するだけでなく、モデルの型によってコンパイル時の誤りに気づけ、堅牢性の高いシステムにすることができます。

クラスを型の状態で表現できる

DDD≠オブジェクト指向

DDDと聞くと、クリーンアーキテクチャやオニオンアーキテクチャの設計方針から、各レイヤーに"クラス"を配置していくことが想起されやすいのではないでしょうか?

ここから DDD ≒ クラス ≒ オブジェクト指向 と思われがちですが、型を使うことでもっと柔軟に設計できることに気がつけます。

関数型プログラミングにおけるは、オブジェクト指向プログラミングにおけるクラスとは異なり、もっとシンプルです。実際、型とは、関数の入力または出力として使用可能な値の集合に与えられた名前にすぎません。
『関数型ドメインモデリング』p.57

また、関数型プログラミングを使うことはクラスを使わないことを意味しません。クラスと型は共存できますし、(この記事では扱いませんが)オニオンアーキテクチャを採用し I/O を端に追いやることもできます。

仕様を整理する

ドメインエキスパートと話し、users のライフサイクルは以下のことが分かったとします。

  1. ユーザーはサインアップ画面でユーザー属性を登録する
  2. ユーザーはアンケートに回答する
  3. アンケートに回答したユーザーはプレゼントを受け取れる
  4. プレゼントを受け取れるユーザーに発送する

これらの仕様は既存のコードにもちろん実装されています。しかし、ドキュメントが常に最新の仕様に保守されていなければ、すべてのコードを読まないと仕様を把握することはできない状態でしょう。

型でビジネスモデルを再設計する

この記事では、実際にすべてを実装するわけではなく、概念実装にとどめておこうと思います。
すべてが上手くいくことは少ないですが、参考になれば幸いです。

分かりやすさを重視して、型名を日本語で書いています。

クラスを複数の関心事に型で分割する

DB駆動設計の場合、1テーブルがそのままクラスに反映されるので、その型は下記のようになっているでしょう。

1テーブル1クラス
type ユーザー = {
    ユーザーID: number
    パスワード: string
    名前: string
    好きな食べ物: string | null
    趣味: string | null
    プレゼント金額: number | null
    プレゼント発送済み: boolean
}

これを、先ほどのライフサイクルに基づいて分割してみます。

ユーザー型をライフサイクルに基づいて分割
type ユーザー属性 = {
    ユーザーID: number
    パスワード: string
    名前: string
}

type アンケート状態 = {
    好きな食べ物: string | null
    趣味: string | null
}

type プレゼント状態 = {
    プレゼント金額: number | null
    プレゼント発送済み: boolean
}

type ユーザー = ユーザー属性 &
               アンケート状態 &
               プレゼント状態

これでユーザーは3つの関心事から構成されていることが分かります。

&|についてはサバイバルTypeScriptの「和集合と共通部分」をご覧ください。)

しかし、これだけではユーザーのライフサイクルが理解できません。

クラスに状態を与える型

もう一度、ライフサイクルを確認すると、ユーザーが生成されるタイミングではアンケートは未回答であるはずです。これを型で表現してみます。

ユーザーのライフサイクル
  1. ユーザーはサインアップ画面でユーザー属性を登録する
  2. ユーザーはアンケートに回答する
  3. アンケートに回答したユーザーはプレゼントを受け取れる
  4. プレゼントを受け取れるユーザーに発送する
アンケート状態
type アンケート未回答 = {
    好きな食べ物: null
    趣味: null
}

type アンケート回答済み = {
    好きな食べ物: string
    趣味: string
}

type アンケート状態 = アンケート未回答 | 
                    アンケート回答済み

これでアンケート状態というのは、未回答と回答済みの2つの状態で構成されていることを表現できました。

プレゼント状態も同様に分割します。

プレゼント状態
type プレゼント受取未確定 = {
    プレゼント金額: null
    プレゼント発送済み: false
}

type プレゼント受取確定 = {
    プレゼント金額: number
    プレゼント発送済み: false
}

type プレゼント発送済み = {
    プレゼント金額: number
    プレゼント発送済み: true
}

type プレゼント状態 = プレゼント受取未確定 | 
                    プレゼント受取確定 | 
                    プレゼント発送済み

ここまで分割すると、ユーザーのライフサイクルを型で表現することができます。

ユーザーのライフサイクル
type アンケート未回答ユーザー = ユーザー属性 & 
                        アンケート未回答 & 
                        プレゼント受取未確定
type アンケート回答済みユーザー = ユーザー属性 & 
                        アンケート回答済み & 
                        プレゼント受取未確定
type プレゼント受取確定ユーザー = ユーザー属性 & 
                        アンケート回答済み & 
                        プレゼント受取確定
type プレゼント発送済みユーザー = ユーザー属性 & 
                        アンケート回答済み & 
                        プレゼント発送済み

type ユーザー = アンケート未回答ユーザー | 
               アンケート回答済みユーザー | 
               プレゼント受取確定ユーザー | 
               プレゼント発送済みユーザー

どうでしょうか?ビジネスモデルを型で表現しましたが、この4つの状態はそのままDBテーブルに永続化できる単位にもなります。

また、自己文書化されたコードになり、仕様書がなくてもコードだけで仕様が伝わります。クラスだけの世界では説明が難しかったでしょう。

型を強制して堅牢性を向上させる

ビジネスモデルを型で表現できることが分かりました。さらに型を使って関数に適切な型のみを渡せるように強制することもできます。1

引数と戻り値の型で関数の役割が明確になる
type ユーザー登録関数型 = (password: string, name: string) => アンケート未回答ユーザー
type アンケート回答関数型 = (user: アンケート未回答ユーザー, favorite_food: string, hobby: string) => アンケート回答済みユーザー
type プレゼント受取判定関数型 = (user: アンケート回答済みユーザー) => プレゼント受取確定ユーザー
type プレゼント発送関数型 = (user: プレゼント受取確定ユーザー) => プレゼント発送済みユーザー

型を強制することで、それぞれの関数に誤った値を渡すことができなくなります。

例えば、最後のプレゼント発送関数型を使って関数を書くと、プレゼント受取確定ユーザー型でないと引数に渡せないことが分かるでしょう。

型を使って関数に適切な型のみを渡せるように強制する
const receivableUser: プレゼント受取確定ユーザー = {
    ユーザーID: 123,
    パスワード: 'gQsxD7dh',
    名前: '山田 太郎',
    好きな食べ物: 'ラーメン',
    趣味: '映画',
    プレゼント金額: 3000,
    プレゼント発送済み: false
};

const sendPresentTo: プレゼント発送関数型 = (user: プレゼント受取確定ユーザー) => {
    return {...user, プレゼント発送済み: true}
}

const sentUser1: プレゼント発送済みユーザー = sendPresentTo(receivableUser)

// 誤った型の値はエラーになり渡せないので堅牢性が向上する
const sentUser2: プレゼント発送済みユーザー = sendPresentTo(sentUser1)
// >>>> 
// Argument of type 'プレゼント発送済みユーザー' is not assignable to parameter of type 'プレゼント受取確定ユーザー'.
//   Type 'プレゼント発送済みユーザー' is not assignable to type 'プレゼント受取確定'.
//     Types of property 'プレゼント発送済み' are incompatible.
//       Type 'true' is not assignable to type 'false'.(2345)

コンパイラーがコードの破壊を防ぐ

型で状態を表現し、それを使うことを強制することで何を得られるのかを考えると、『Good Code, Bad Code』の下記の部分が参考になります。

他のエンジニアがどのようにコードを壊したり誤用したりするかを考え、それらが起こる可能性を最小限にするようにうまく処理することは役に立つ
...(中略)
通常、コンパイラーを利用して契約を強制することが最も信頼できるアプローチである。それが不可能ならば、検査とアサーションを利用した実行時における契約の強制が、代替案としてある
『Good Code, Bad Code』Chapter3 コードでの契約 p.74

テストは重要ですが、そもそもコンパイルエラーによってコードの契約を守らせることはもっと重要です。例で示した sendPresentTo 関数に誤った引数を渡すことができないことはコンパイル時に気づけ、誤ったコードが本番環境に出てしまうこともなくなります。

全サンプルコード

TypeScript Playground に貼り付けると見やすいと思います

すべての実装コード
// ユーザー属性
type ユーザー属性 = {
    ユーザーID: number
    パスワード: string
    名前: string
}

// アンケート状態
type アンケート未回答 = {
    好きな食べ物: null
    趣味: null
}
type アンケート回答済み = {
    好きな食べ物: string
    趣味: string
}
type アンケート状態 = アンケート未回答 | アンケート回答済み

// プレゼント状態
type プレゼント受取未確定 = {
    プレゼント金額: null
    プレゼント発送済み: false
}
type プレゼント受取確定 = {
    プレゼント金額: number
    プレゼント発送済み: false
}
type プレゼント発送済み = {
    プレゼント金額: number
    プレゼント発送済み: true
}
type プレゼント状態 = プレゼント受取未確定 | プレゼント受取確定 | プレゼント発送済み

// ユーザーのライフサイクル
type アンケート未回答ユーザー = ユーザー属性 & アンケート未回答 & プレゼント受取未確定
type アンケート回答済みユーザー = ユーザー属性 & アンケート回答済み & プレゼント受取未確定
type プレゼント受取確定ユーザー = ユーザー属性 & アンケート回答済み & プレゼント受取確定
type プレゼント発送済みユーザー = ユーザー属性 & アンケート回答済み & プレゼント発送済み

// ユーザーはライフサイクルで表現できる
type ユーザー = アンケート未回答ユーザー | アンケート回答済みユーザー | プレゼント受取確定ユーザー | プレゼント発送済みユーザー

// 型で関数に渡せる引数を強制できる
type ユーザー登録関数型 = (password: string, name: string) => アンケート未回答ユーザー
type アンケート回答関数型 = (user: アンケート未回答ユーザー, favorite_food: string, hobby: string) => アンケート回答済みユーザー
type プレゼント受取判定関数型 = (user: アンケート回答済みユーザー) => プレゼント受取確定ユーザー
type プレゼント発送関数型 = (user: プレゼント受取確定ユーザー) => プレゼント発送済みユーザー


// 実装サンプル
const receivableUser: プレゼント受取確定ユーザー = {
    ユーザーID: 123,
    パスワード: 'gQsxD7dh',
    名前: '山田 太郎',
    好きな食べ物: 'ラーメン',
    趣味: '映画',
    プレゼント金額: 3000,
    プレゼント発送済み: false
};

const sendPresentTo: プレゼント発送関数型 = (user: プレゼント受取確定ユーザー) => {
    return {...user, プレゼント発送済み: true}
}

const sentUser1: プレゼント発送済みユーザー = sendPresentTo(receivableUser)

// 誤った型の値は渡せないので堅牢性が向上する
const sentUser2: プレゼント発送済みユーザー = sendPresentTo(sentUser1)
// >>>> Type 'true' is not assignable to type 'false'.

まとめ

DDD(ドメイン駆動設計)と関数型ドメインモデリングはカオスなDB設計を救えたでしょうか?少なくとも、下記のことを実現できました。

  • ドメインエキスパートとビジネスモデルを整理したことで、1つのテーブルを適切な複数のクラス(型)に分割できた
  • 関数型ドメインモデリングにより、コードが自己文書化され仕様を理解しやすくなった
  • コンパイル時のエラーにより、コードの破壊を防ぐことができた

今回は、TypeScript でサンプルコードを示しました。TypeScript の型システムの制約を補うために、Branded Typesなどを使うことも必要になるでしょう。
型の導入には初期の学習コストがかかりますが、長期的な運用コストの削減が期待できます。積極的に取り入れていきたい手法だと思いました。

参考


  1. 実際はTypeScriptはxxxxなのでBranded Typesなどを使って強制することになるでしょう

6
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?