🥐

【Scala】型で状態を表現することでビジネスルールを把握しやすくする

2025/02/10に公開

はじめに

最近、色々な人がオススメしていたこともあり「関数型ドメインモデリング」を読みました。
https://asciidwango.jp/post/754242099814268928/関数型ドメインモデリング

この書籍はサンプルコードがF#で記載されています。
そこで、業務で利用しているScalaに書き換えた場合にどのようになるのか気になり、実際に書いてみたので備忘録として残します。

書籍の中でよく用いられている手法として、ある要素が複数の状態をとる時、状態ごとに独自の型を作るというものがありました。
個人的に、この手法により型の利用の幅が広がったと感じたため、今回はこの手法について記載します。

この記事について

書籍「関数型ドメインモデリング」の「6.4 ビジネスルールを型システムで表現する」の内容を、Scalaコードを使用して、自分なりの理解でまとめなおしたものです。

この記事の内容は、書籍を読んでいなくても理解できると思います。
また、書籍でよく用いられていた手法を説明するため、この記事を読んだ後に興味が出たら書籍を読むといった流れも可能だと思います。

サンプルコード実行環境

Scala 3.3.4
(いくつかScala3特有の記法を使用しています)

本文

ビジネスフローの中で、ある要素の状態が変化していくことはよくあります。
これを型で表現することでビジネスルールが把握しやすくなることを示します。

複数の状態を表現する

一般的な方法と型で表現する方法で比較します。

ここでは例として、次のような場合を考えます。

  • 顧客のメールアドレスが検証済み、未検証という2つの状態をとる
  • 顧客メールアドレス関連の処理に次のようなビジネスルールが存在する
    • 検証メールは未検証のメールアドレスにのみ送信する
    • パスワードリセットのメールは検証済みのメールアドレスにのみ送信する

一般的な方法で状態を表現する

まずは、顧客メールアドレスの実装です。

// メールアドレス
opaque type EmailAddress = String
object EmailAddress {
  def apply(address: String): EmailAddress = address
}

// 顧客のメールアドレス 
case class CustomerEmail(
  emailAddress: EmailAddress,
  isVerified: Boolean
)

まずはopaque typeを使用してEmailAddressを定義しています。

opaque typeについて

opaque typeを使用することで、内部的にはStringだがopaque typeが定義されているスコープ外では宣言した型として扱われます。
詳細は公式ドキュメントを確認してください。

さらに、EmailAddressを使用してCustomerEmailを定義しています。
検証済みかどうかはisVerifiedフラグで判別しています。

次に、メールアドレス関連の処理の実装です。

// 未検証メールアドレスに検証メールを送信する
def sendVerificationEmail(email: CustomerEmail): Unit = {
  if (email.isVerified) {
    ... // 何もしない
  } else {
    ... // 検証メールを送信する
  }
}
// 検証済みメールにパスワードリセットメールを送信する
def sendPasswordResetEmail(email: CustomerEmail): Unit = {
  if (email.isVerified) {
    ... // パスワードリセットのメールを送信する
  } else {
    ... // 何もしない
  }
}

この実装からビジネスルールを把握するには以下の2つの方法があります。

  1. コメントを確認する
  2. 内部実装を確認する

しかしこれら2つの方法には以下のような問題点があります。

  1. コメントの説明とビジネスルールにずれが生じる可能性がある
  2. 内部実装が複雑な場合、ビジネスルールを理解するのに時間がかかる

型で状態を表現する

型で状態を表現することで上記の問題を解決できることを示します。

まず、顧客メールアドレスの実装です。EmailAddressとVerifiedEmailAddressはCustomerEmailの内部で保持する値です。

// メールアドレス
opaque type EmailAddress = String
object EmailAddress {
  def apply(address: String): EmailAddress = address
}

// 検証済みのメールアドレス
opaque type VerifiedEmailAddress = String
object VerifiedEmailAddress {
  private[verification] def apply(address: String): VerifiedEmailAddress = address
}

// 顧客のメールアドレス(2つの状態をまとめた型)
enum CustomerEmail {
  case Unverified(address: EmailAddress) // 未検証
  case Verified(address: VerifiedEmailAddress) // 検証済み
}
VerifiedEmailAddressのコンストラクタをパッケージプライベートにしている理由について

VerifiedEmailAddressのコンストラクタをパッケージプライベートにすることで、コンストラクタのアクセス可能範囲をコンパニオンや指定したパッケージに限定しています。
本筋ではないので具体例は省略していますが、検証処理が完了した時だけVerifiedEmailAddressを生成し、それ以外では作成できないようにするといったことが可能になります。

次に、顧客メールアドレス関連の処理は次のような実装になります。

def sendVerificationEmail(address: EmailAddress): Unit = { 
  ... // 検証メールを送信する
}
def sendPasswordResetEmail(address: VerifiedEmailAddress): Unit = {
  ... // パスワードリセットのメールを送信する
}

この実装であればシグネチャを確認することでビジネスルールを理解することが可能です。
そのため、コメントに頼る必要や実装を確認する必要がありません。

書籍では、これらの型は非エンジニアでも理解しやすいため、共通の言語として利用できると説明されています。

CustomerEmail型を使うタイミングについて

顧客メールアドレス関連の処理では引数としてCustomerEmailの内部の値を受け取っています。
CustomerEmail型を使うタイミングは、外部システムや別コンテキストから受け取った値をモデルに変換したときです。
CustomerEmail型の値に上記の関数を適用する場合は、パターンマッチを使用します。以下はsendVerificationEmailの例です。

customerEmail match {
  case CustomerEmail.Unverified(address) => sendVerificationEmail(address)
  case CustomerEmail.Verified(_) => ()
}

ワークフローに応用する

では、状態を型で表現する手法を書籍でどのように利用しているのかを簡単に説明します。

書籍では複数の関数を合成して、ワークフローというビジネスの主な処理を構築しています。

書籍で説明されている注文ワークフローは以下の図の左の列のようになります。この図では、楕円が状態、長方形が処理を表しています。

出典: 関数型ドメインモデリングP.35

図を見てわかるように 、処理を適用することで注文を異なる状態に遷移させ、それを次の処理の入力とすることでワークフローを構築しています。
このようにワークフローを構築することで、ビジネスフローを把握しやすくなります。
それに加え、処理の適用忘れを防ぐことができる、処理の内容が変更されても変更範囲を限定できるなどの利点もあります。

ワークフローの詳細については書籍を参照してください。

おわりに

状態をどのように型で表現するか、型で表現することでビジネスルールが把握しやすくなることを確認しました。
また、この手法が書籍でどのように利用されているかを簡単に示しました。

ここで説明した内容は書籍の中のごく一部です。
DDDの戦略的設計に関係する部分、関数型らしい関数合成やエラー処理など、その他のトピックについても説明されています。

nextbeat Tech Blog

Discussion