♻️

状態遷移の悩みを解決!DDDでシンプルかつ堅牢な状態遷移の設計を実現する方法

2024/12/09に公開

はじめに

ドメイン駆動設計(DDD)は、ビジネスドメインをソフトウェアに正確に反映させるための強力なアプローチです。その中でも、状態遷移の設計は、ドメインの振る舞いを表現するための核となる部分です。ドメインモデルで「状態」をどのように扱うかは、システム全体の品質に大きな影響を与えます。

例えば、注文管理システムでは、注文が「未処理(注文時)」→「処理中」→「配送中」→「配送完了」と遷移する過程を管理する必要があります。この状態遷移を正しく設計しないと、システムが次第に複雑化し、コードの可読性や保守性が低下します。最悪の場合、ビジネスロジックの不整合やバグが発生しやすくなるため、注意が必要です。

状態遷移設計の課題

ナイーブな状態遷移の実装における典型的な課題には、以下のようなものがあります。

本質的ではない条件分岐

ドメインロジックの本質ではない条件分岐をドメイン層に書かなければいけません。さらに、状態が増える度に条件分岐が増えます。

状態ごとのドメイン知識が乱雑になる

各状態に関連するドメイン知識が条件分岐内に混ざり込み、高結合・低凝集になりがちです。これにより、ビジネスルールの変更や機能追加が非常に難しくなり、バグの原因にもなり得ます。

テストの困難さ

状態遷移に伴う条件分岐が複雑になると、テストが難しくなります。特に、状態が増えた場合、全ての状態遷移を網羅するテストケースを作成するのが難しく、抜け漏れが発生しやすくなります。

DDD×ステートマシンパターンが解決する力

エンティティの状態遷移の新しい管理方法として「ステートマシンパターン」を提案します。ステートマシンパターンにより、ドメインの複雑な状態遷移をシンプルかつ堅牢に実装することができます。

ステートマシンパターンとは

状態遷移のロジックを疎結合・高凝集かつ型安全に表現することを目的としたデザインパターンです。取りうる状態をそれぞれエンティティとして個別に定義し、エンティティ毎に固有の振る舞いをメソッドとして記述します。

class A {
  fun toB() = B() // 状態Bに遷移
  fun doAnythingOnlyWhenA() // 状態A固有の振る舞い
}

class B {
  fun toA() = A() // 状態Aに遷移
  fun toC() = C() // 状態Cに遷移
  fun doAnythingOnlyWhenB() // 状態B固有の振る舞い
}

class C {
  fun doAnythingOnlyWhenC() // 状態C固有の振る舞い
}

メリット

状態遷移ルールの明確化

ある状態についての遷移先をメソッドの戻り値で表現するだけのため、状態遷移のフローが直感的に理解しやすくなります。

疎結合・高凝集

状態毎のロジックを各エンティティに集約することで、他のクラスとの依存関係が減り、ロジックを柔軟に変更することができます。新たな状態を追加する際にも、既存のクラスに影響を与えることなく状態を拡張できます。

型安全

状態をEnumで表現する場合、遷移させる前に現在の状態が正しいか検証するロジックが必要になることが多いです。

if (entity.state != State.A) throw IllegalStateException("不正な遷移です")
entity.doAnythigOnlyWhenA()

ステートマシンパターンではエンティティ毎に振る舞いを定義するため、変数が振る舞いが定義されている型であることを確定させてから呼び出す必要があります。コンパイルさえ通っていれば不正な状態での呼び出しが必ず弾けるため、簡潔にロジックを記述することができます。

テストの容易さ

疎結合・高凝集かつ型安全に状態遷移を表せるデザインパターンなため、単体テストは最小限で済みます。各メソッドが正しい振る舞いをしているかについてのみ検証すればよく、遷移にフォーカスしたテストは不要です。

ステートパターンとの違い

状態とそれに対する振る舞いをセットに記述できるデザインパターンとしてステートパターンが挙げられます。ステートパターンは凝集度を高めながら各状態を透過的に扱えることを目的としており、オブジェクト指向の多態性を用いて実現しています。

例:キャラクターの状態により行動のパフォーマンスを変化させるケース
// キャラクターのステートインターフェース
interface CharacterState {
    fun runSpeed(): Int
    fun jumpHeight(): Int
    fun exhaust(): CharacterState  // 体力が減少した際に次の状態に遷移
    fun energize(): CharacterState  // 体力が回復した際に次の状態に遷移
}

// 完全な健康状態(フルヘルス)
class FullHealthState : CharacterState {
    override fun runSpeed(): Int = 10   // 高速で走る
    override fun jumpHeight(): Int = 5  // 高く飛べる

    override fun exhaust(): CharacterState {
        // 体力が減少した場合、次の状態(中程度または低ヘルス)に遷遷
        return ModerateHealthState()
    }

    override fun energize(): CharacterState {
        // すでにフルヘルスなので状態は変わらない
        return this
    }
}

// 健康が中程度の状態(ヘルスが半分くらい)
class ModerateHealthState : CharacterState {
    override fun runSpeed(): Int = 5    // 普通の速さで走る
    override fun jumpHeight(): Int = 3  // 普通に飛ぶ

    override fun exhaust(): CharacterState {
        // 体力が減少した場合、次の状態(低ヘルス)に遷移
        return LowHealthState()
    }

    override fun energize(): CharacterState {
        // 体力が回復した場合、フルヘルスに戻る
        return FullHealthState()
    }
}

// 体力が低い状態(ヘルスが少ない)
class LowHealthState : CharacterState {
    override fun runSpeed(): Int = 2    // 遅く走る
    override fun jumpHeight(): Int = 1  // 低くしか飛べない

    override fun exhaust(): CharacterState {
        // 低ヘルスでは状態が変わらない
        return this
    }

    override fun energize(): CharacterState {
        // 体力が回復した場合、中程度の健康状態に遷移
        return ModerateHealthState()
    }
}

// キャラクタークラス(体力を管理するだけ)
class Character(var state: CharacterState) {
    fun runSpeed(): Int = state.runSpeed()
    fun jumpHeight(): Int = state.jumpHeight()

    // 体力を減らす(状態遷移)
    fun exhaust() {
        state = state.exhaust()
    }

    // 体力を回復させる(状態遷移)
    fun energize() {
        state = state.energize()
    }
}

fun main() {
    val character = Character(FullHealthState())  // 初期状態はフルヘルス

    // 初期状態:フルヘルス
    println("走る速さ: ${character.runSpeed()}m/s, 飛ぶ高さ: ${character.jumpHeight()}m")
    // 走る速さ: 10m/s, 飛ぶ高さ: 5m

    // 体力を減らす
    character.exhaust()
    println("体力: 減少後, 走る速さ: ${character.runSpeed()}m/s, 飛ぶ高さ: ${character.jumpHeight()}m")
    // 体力: 減少後, 走る速さ: 5m/s, 飛ぶ高さ: 3m

    // 体力を回復させる
    character.energize()
    println("体力: 回復後, 走る速さ: ${character.runSpeed()}m/s, 飛ぶ高さ: ${character.jumpHeight()}m")
    // 体力: 回復後, 走る速さ: 10m/s, 飛ぶ高さ: 5m

    // 体力をさらに減らす
    character.exhaust()
    println("体力: 減少後, 走る速さ: ${character.runSpeed()}m/s, 飛ぶ高さ: ${character.jumpHeight()}m")
    // 体力: 減少後, 走る速さ: 5m/s, 飛ぶ高さ: 3m
}

しかし、今回提案するステートマシンパターンは一般的なステートパターンとは似て非なるものであり、多態を活用しない真逆のアプローチです。多態による共通化がない代わりに各エンティティに遷移ロジックを自由に定義することで、シンプルながらも明確で柔軟、かつ堅牢な状態遷移のドメインロジックを実現します。

DDDとの相性が抜群

ステートマシンパターンは複雑な状態遷移のドメインロジックを疎結合・高凝集に記述できるため、DDDと非常に相性が良いです。DDDでは、ドメイン知識を「ドメインモデル」に集約し、そのモデルを通じてビジネスロジックを実現することが求められます。ステートマシンパターンによって各状態が自分自身で遷移のロジックを持ち、他のロジックにドメイン知識を漏らすことなく振る舞うようになることで、ドメインオブジェクトが持つべき責務が最小限になります。

実装例

ステートマシンパターンを使うことで、注文管理システムの状態遷移をどのように簡潔に表現できるかを見ていきましょう。このシステムでは、注文が「未処理(注文時)」、「処理中」、「配送中」、「配送完了」という状態を持っています。それぞれの状態を独立したクラスとして管理し、状態ごとの振る舞いを分けることで、状態遷移の複雑さを大幅に減らします。

以下のコード例では、各状態のクラスが自身の状態遷移を管理し、状態遷移の型安全性を保証します。

注文エンティティ

まず、注文の状態を表現するためのクラスを設計します。各状態は、遷移先を明確に定義するメソッドを持ち、状態遷移が型レベルで保証されるようにします。

// 注文IDの型
data class OrderId(val value: String)

// 基本的なOrderインターフェース
interface Order {
    val id: OrderId
}

// 各状態クラス
data class NewOrder(override val id: OrderId) : Order {
    fun process(): ProcessingOrder {
        return ProcessingOrder(id)
    }
}

data class ProcessingOrder(override val id: OrderId) : Order {
    fun ship(): ShippingOrder {
        return ShippingOrder(id)
    }
}

data class ShippingOrder(override val id: OrderId) : Order {
    fun deliver(): DeliveredOrder {
        return DeliveredOrder(id)
    }
}

data class DeliveredOrder(override val id: OrderId) : Order

この設計では、各状態(NewOrderProcessingOrderShippingOrderDeliveredOrder)が遷移のメソッドを持ち、その戻り値が遷移先の状態クラスとなっています。状態に対する遷移先が明確に示され、実行時に誤った状態遷移が発生するリスクをなくすことができます。

注文リポジトリ

OrderRepositoryには、状態ごとのオブジェクトを保存・取得するためのインターフェースを定義します。

interface OrderRepository {
    // 新規注文を保存し、その後処理中に遷移
    fun saveNewOrder(order: NewOrder): ProcessingOrder

    // 処理中の注文を取得・保存
    fun findProcessingOrder(id: OrderId): ProcessingOrder?
    fun saveProcessingOrder(order: ProcessingOrder)// 配送中の注文を取得・保存
    fun findShippingOrder(id: OrderId): ShippingOrder?
    fun saveShippingOrder(order: ShippingOrder)// 配送完了の注文を取得・保存
    fun findDeliveredOrder(id: OrderId): DeliveredOrder?
    fun saveDeliveredOrder(order: DeliveredOrder)
}

状態ごとに保存・取得のメソッドを分けることで、これらのメソッドの呼び出し元・呼び出し先の条件分岐を削減できます。ただし、引数の型をOrder インターフェースにした方が都合がいいケースもり、実装したいドメインのルールによって適切な方を使いましょう。

アプリケーション層

アプリケーション層で各状態を操作するユースケースを定義してみます。状態遷移のメソッドが型安全であるため、状態遷移が実行時にエラーを引き起こさないように保障されています。

class OrderUseCase(private val orderRepository: OrderRepository) {
    fun createOrder(orderId: OrderId) {
        val newOrder = NewOrder(orderId)
        val processingOrder = orderRepository.saveNewOrder(newOrder)
        orderRepository.saveProcessingOrder(processingOrder)
    }
}

class ShipOrderUseCase(private val orderRepository: OrderRepository) {
    fun shipOrder(id: OrderId) {
        val processingOrder = orderRepository.findProcessingOrder(id)
        if (processingOrder == null) throw IllegalStateException("対象の注文がないか、処理中ではありません")
        val shippingOrder = it.ship()
        orderRepository.saveShippingOrder(shippingOrder)
    }
}

class CompleteShippingUseCase(private val orderRepository: OrderRepository) {
    fun completeShipping(id: OrderId) {
        val shippingOrder = orderRepository.findShippingOrder(id)
        if (shippingOrder == null) throw IllegalStateException("対象の注文がないか、配送中ではありません")
        val deliveredOrder = it.deliver()
        orderRepository.saveDeliveredOrder(deliveredOrder)
    }
}

状態ごとに定義されたメソッドを呼び出すだけです。型安全な状態遷移であるため、コンパイル時に誤った遷移を完全に防ぐことができます。

ポイント

  • 疎結合・高凝集: 各状態が自分自身の振る舞いを管理し、遷移ロジックが状態ごとに独立しているため、コードが高凝集で疎結合になります。これにより、状態遷移に関わる変更が他の部分に影響を与えません。
  • 型安全性: 各状態クラスはその遷移先の状態を戻り値として持っているため、状態遷移がコンパイル時に検証されます。これにより、実行時エラーを防ぎ、状態遷移の正当性が保証されます。
  • テストのしやすさ: 各状態が独立しているため、状態ごとのロジックを個別にテストしやすくなります。また、型安全に状態遷移ができるため、間違った状態遷移についてテストで検証する必要がありません。
  • (おまけ)Repositoryで各状態に対応するsaveとfindメソッドを定義することで、取得したエンティティの状態を確認せずに処理をすることが可能です。

おわりに

今回の実装例では、ドメイン駆動設計(DDD)の原則を守りつつ、ステートマシンパターンを活用して状態遷移をシンプルかつ明確に設計しました。このアプローチは、状態遷移が複雑になりがちなシステムにおいて、状態ごとの振る舞いを独立して管理できるという大きなメリットを提供します。

特に、状態遷移に伴う条件分岐が減り、型安全性が確保されることで、システムの保守性とテストのしやすさが向上します。状態が増えても新しい状態を追加するだけで済むため、柔軟で拡張性のあるシステム設計が可能です。DDDに基づいた状態遷移設計を採用することで、ビジネスロジックが整理され、システム全体の品質が向上します。

本記事では、DDDにおける状態遷移の管理方法に対する新たなアプローチについて詳しく説明しました。このアプローチは、状態遷移をシンプルかつ明確に管理できるため、状態管理が複雑になりがちなシステムにおいて非常に実践的かつ有効的です。各状態を独立したクラスとして管理し、状態ごとに適切な保存・遷移処理を行うことで、コードの可読性と保守性が大幅に向上します。
実際にログラスのコード上でも採用されており、手軽に取り入れることができるので、ぜひこのアプローチを試してみてはいかがでしょうか?

株式会社ログラス テックブログ

Discussion