🐳

ディシジョン系の仕様をエンコードする「アクティブパターン」

2024/08/19に公開

ログラスでは、型によるドメインモデリングや関数型由来のエッセンスを取り入れて、プロダクトコードの偶有的複雑性を抑えようとする取り組みが盛んです。

その中でも大いに参考にしている、みんな大好き(?)関数型ドメインモデリングにて「アクティブパターン」というF#の機能がちらっと紹介されていました。

これは代数的データ型(a.k.a. ADTs、タグ付きユニオン、判別共用体...)によるドメインモデリングを前提したときの、ディシジョン系の仕様の直感的なコード表現に有用と感じ、Kotlinに適用しつつ紹介したいと思います。

概要

  • そもそも"ディシジョン系の仕様"とは、お前は何をイメージして言っているんだ
    ※ 個人的な分類です!

  • 最初にコードの最終形・全体像を共有します。(詳しくは後段にて、順を追って説明させてください!)
  • サンプルストーリを元に、まず今回のドメインにおけるビジネスルールと、それをKotlinに翻訳したコードを、ざっと対比して眺めてみます。

ドメイン: 発送コスト見積りポリシー

  • 決定のビジネスロジック: 発送コスト見積りポリシー
条件 見積り結果
▽ 配達先 ▽ メンバーシップ ▽ 送料 ▽ 配達保証日数
近隣県 一般 ¥500 3日
vip 0 1日
遠方県 一般 ¥1,000 5日
vip 0 3日
海外 - ¥3,000 ベストエフォート

DMN仕様を参考に表現

コード

ShipingCostPolicy.kt
fun estimateShippingCost(address: Address, orderCount: Int): ShippingEstimate {
    val destination = DestinationCategory.pattern(address)
    val membership = MembershipCategory.pattern(orderCount)

    return when (destination) {
        is JapanNearby -> when (membership) {
            is General ->
                ShippingEstimate(fee = 500, days = TimeCommitment.Days(3))
            is VIP ->
                ShippingEstimate(fee = 0, days = TimeCommitment.Days(1))
        }
        is JapanDistant -> when (membership) {
            is General ->
                ShippingEstimate(fee = 1000, days = TimeCommitment.Days(5))
            is VIP ->
                ShippingEstimate(fee = 0, days = TimeCommitment.Days(3))
        }
        is Overseas ->
            ShippingEstimate(fee = 3000, days = TimeCommitment.BestEffort)
    }
}

// 見積り結果
data class ShippingEstimate(
    val fee: Int,
    val days: TimeCommitment
)
    sealed class TimeCommitment {
        data class Days(val count: Int) : TimeCommitment()
        data object BestEffort : TimeCommitment()
    }

ドメイン: 配達先分類

  • 知識のビジネスロジック①: 配達先分類
  • 近隣県とは、「"東京都", "神奈川県", "埼玉県", "千葉県","山梨県", "群馬県", "栃木県"」を指す
  • 遠方県とは、国内の上記以外の県を指す
  • 海外とは日本以外の配送先を指す

コード

DestinationPolicy.kt
sealed class DestinationCategory {
// それぞれが自身の条件を持つアクティブなパターンたちが DestinationCategoryというラベルでクリップ留めされている
    data object JapanNearby : DestinationCategory() // Tips: ユニオンの列挙にdata objectが便利
    data object JapanDistant : DestinationCategory()
    data object Overseas : DestinationCategory()

    companion object {
        private val nearbyPrefectures = setOf(
            "東京都", "神奈川県", "埼玉県", "千葉県",
            "山梨県", "群馬県", "栃木県"
        )

        fun pattern(address: Address): DestinationCategory {
            return when (address) {
                is Address.Domestic -> when {
                    // "近隣県"というドメイン用語の定義
                    nearbyPrefectures.contains(address.prefecture) -> JapanNearby
                    // "遠方県"というドメイン用語の定義
                    else -> JapanDistant
                }
                is Address.International -> Overseas
            }
        }
    }
}

ドメイン: メンバーシップ分類

  • 知識のビジネスロジック②: メンバーシップ分類
  • VIP会員とは、累計注文数が10回以上のユーザのことを指す
  • 一般会員とは、それ以外のユーザのことを指す

コード

MembershipPolicy.kt
sealed class MembershipCategory {
    data object General : MembershipCategory() // 一般会員
    data object VIP : MembershipCategory() // VIP会員

    companion object {
        fun pattern(count: Int): MembershipCategory = when {
            count >= 10 -> VIP
            else -> General
        }
    }
}

☺️ 何が嬉しい?

  • 複雑かつ変更のホットスポットになりがちなロジックを、抽出し識別できる(ドメイン解像度・変更容易性👍)
    • ドメイン駆動設計の営みそのもの
  • 知識による分類のロジックと、分類による決定ポリシーの関心事を分離(変更容易性👍)
    • ex) "茨城県"を近隣県に加えるというビジネス変化が起きた -> DestinationCategoryのみ変更すればOK
  • 決定ポリシーが比較的そのままコードに写されており直感的と思われる(可読性👍)
    • 元の表構造がすっきり保たれているのではないかと感じます
    • 「コンパイル・実行可能な仕様」と言えるほどに、ドメイン知識を宣言的に語るコードを目指したいものです
📍コード全体(上述のものはポイント箇所の抜粋になります)
ShipingCostPolicy.kt
package shipping.estimate

import shipping.destination.DestinationCategory.*
import shipping.membership.MembershipCategory.*

// 決定のロジック
data class ShippingEstimate(
    val fee: Int,
    val days: TimeCommitment
)
    sealed class TimeCommitment {
        data class Days(val count: Int) : TimeCommitment()
        data object BestEffort : TimeCommitment()
    }
fun estimateShippingCost(address: Address, orderCount: Int): ShippingEstimate {
    val destination = DestinationCategory.pattern(address)
    val membership = MembershipCategory.pattern(orderCount)

    return when (destination) {
        is JapanNearby -> when (membership) {
            is General ->
                ShippingEstimate(fee = 500, days = TimeCommitment.Days(3))
            is VIP ->
                ShippingEstimate(fee = 0, days = TimeCommitment.Days(1))
        }
        is JapanDistant -> when (membership) {
            is General ->
                ShippingEstimate(fee = 1000, days = TimeCommitment.Days(5))
            is VIP ->
                ShippingEstimate(fee = 0, days = TimeCommitment.Days(3))
        }
        is Overseas ->
            ShippingEstimate(fee = 3000, days = TimeCommitment.BestEffort)
    }
}
DestinationPolicy.kt
package shipping.destination

// 知識による分類ロジック
sealed class DestinationCategory {
    data object JapanNearby : DestinationCategory() // Tips: ユニオンの列挙にdata objectが便利
    data object JapanDistant : DestinationCategory()
    data object Overseas : DestinationCategory()

    companion object {
        private val nearbyPrefectures = setOf(
            "東京都", "神奈川県", "埼玉県", "千葉県",
            "山梨県", "群馬県", "栃木県"
        )

        fun pattern(address: Address): DestinationCategory {
            return when (address) {
                is Address.Domestic -> when {
                    // "近隣県"というドメイン用語の定義
                    nearbyPrefectures.contains(address.prefecture) -> JapanNearby
                    // "遠隔県"というドメイン用語の定義
                    else -> JapanDistant
                }
                is Address.International -> Overseas
            }
        }
    }
}
MembershipPolicy.kt
package shipping.membership

sealed class MembershipCategory {
    data object General : MembershipCategory() // 一般会員
    data object VIP : MembershipCategory() // VIP会員

    companion object {
        fun pattern(count: Int): MembershipCategory = when {
            count >= 10 -> VIP
            else -> General
        }
    }
}
ShippingWorkflow.kt
package shipping.shipping_service

import shipping_estimate.estimateShippingCost

// 使用例のテスト
fun main() {
    val customer = Customer(
        userId = "new_user",
        country = "日本",
        address = Address.Domestic(prefecture = "東京都", detail = "千代田区")
    )
    val customer2 = Customer(
        userId = "vip_user",
        country = "日本",
        address = Address.Domestic(prefecture = "神奈川県", detail = "横浜")
    )
    val customer3 = Customer(
        userId = "user",
        country = "アメリカ",
        address = Address.International(detail = "カリフォルニア")
    )

    shippingWorkflow(customer)
    // Order is being shipped with the following details:
    // 送料: ¥500
    // 配達日数目標: Days(count=3)
    shippingWorkflow(customer2)
    // Order is being shipped with the following details:
    // 送料: ¥0
    // 配達日数目標: Days(count=1)
    shippingWorkflow(customer3)
    // Order is being shipped with the following details:
    // 送料: ¥3000
    // 配達日数目標: BestEffort
}

// 意思決定材料のinputデータ
data class Customer(
    val userId: String,
    val country: String,
    val address: Address,
)
    sealed class Address {
        data class Domestic(
            val prefecture: String, // 簡単のためStringで
            val detail: String
        ) : Address()
        data class International(
            val detail: String // Tips: "海外"だが"居住県"が入力されるという事態はそもそも型で起こさない
        ) : Address()
    }
// 発送業務
fun shippingWorkflow(customer: Customer) {
    totalOrderCount(customer.userId)
        .let { orderCount -> estimateShippingCost(customer.address, orderCount) } //コスト見積もり業務
        .let { estimate -> shipOrder(estimate) }
}
    fun totalOrderCount(userId: String): Int {
        val orderHistoryState = mapOf(
            "user" to 8,
            "vip_user" to 15,
        )
        return orderHistoryState[userId] ?: 0
    }
    fun shipOrder(estimate: ShippingEstimate) {
        println("Order is being shipped with the following details:")
        println("送料: ¥${estimate.fee}")
        println("配達日数目標: ${(estimate.days)}")
    }

段階的な説明

さて、実は先ほどお見せしたコードは初期リリースよりいくつかのビジネス変化を経た後の仕様(コード)でした。
時間を巻き戻して、段階的に仕様の変遷を見ていきましょう。

1. 送料決定戦略のエンコーディング: アクティブパターンのミニマム

あなたたちは、近隣の顧客に対する満足度の向上と、コスト効率の向上(長距離輸送にかかる追加コストをカバー)を目的とし、自社倉庫から配達先への距離に応じ、配達送料を可変とする施策を行います。

  • 送料ポリシー
条件 見積り結果
▽配達先 ▽送料
近隣県 ¥500
遠方県 ¥1,000
海外 ¥3,000

早速、ドメインエキスパートから聞いた知識をコードへ翻訳していきましょう。

first.kt
import DestinationCategory.*

// 意思決定材料のinputデータ
sealed class Address {
    data class Domestic(
        val prefecture: String, // 簡単のためStringで
        val detail: String
    ) : Address()
    data class International(
        val detail: String // Tips: "海外"だが"居住県"が入力されるという事態はそもそも型で起こさない
    ) : Address()
}

// 決定のロジック
fun decideShippingFee(address: Address): Int {
    val destination = DestinationCategory.pattern(address)
    return when (destination) {
        is JapanNearby -> 500
        is JapanDistant -> 1000
        is Overseas -> 3000
    }
}

// 知識による分類ロジック
sealed class DestinationCategory {
    data object JapanNearby : DestinationCategory() // Tips: ユニオンの列挙にdata objectが便利
    data object JapanDistant : DestinationCategory()
    data object Overseas : DestinationCategory()

    companion object {
        private val nearbyPrefectures = setOf(
            "東京都", "神奈川県", "埼玉県", "千葉県",
            "山梨県", "群馬県", "栃木県"
        )

        fun pattern(address: Address): DestinationCategory {
            return when (address) {
                is Address.Domestic -> when {
                    // "近隣県"というドメイン用語の定義
                    nearbyPrefectures.contains(address.prefecture) -> JapanNearby
                    // "遠隔県"というドメイン用語の定義
                    else -> JapanDistant
                }
                is Address.International -> Overseas
            }
        }
    }
}

DestinationCategoryは、それ自体が自身の分類ロジックを持つ、アクティブなパターンです
decideShippingFeeは、すっきりと意思決定のストラテジー(ex. 近隣県だったら500円だー)のみをもつ、決定ロジックです。

2. 仕様変更: アウトプットの追加

あなたたちは、更なる顧客信頼度の向上を目的とし、「発送先地域に応じてお届け日数をコミットメントする」という施策を実行します

  • 発送コスト見積りポリシー
条件 見積り結果
▽配達先 ▽送料 ▽配達保証日数
近隣県 ¥500 3日
遠方県 ¥1,000 5日
海外 ¥3,000 ベストエフォート
second.kt
import DestinationCategory.*

// 意思決定材料のinputデータ
sealed class Address {
    data class Domestic(
        val prefecture: String,
        val detail: String
    ) : Address()
    data class International(
        val detail:
    ) : Address()
}

// 決定のロジック
+ fun estimateShippingCost(address: Address): ShippingEstimate {
- fun estimateShippingCost(address: Address): Int {
    val destination = DestinationCategory.pattern(address)
    return when (destination) {
+        is JapanNearby -> ShippingEstimate(fee = 500, days = TimeCommitment.Days(3))
-        is JapanNearby -> 500
+        is JapanDistant -> ShippingEstimate(fee = 1000, days = TimeCommitment.Days(5))
-        is JapanDistant -> 1000
+        is Overseas -> ShippingEstimate(fee = 3000, days = TimeCommitment.BestEffort)
-        is Overseas -> 3000
    }
}


// 知識による分類ロジック
sealed class DestinationCategory {
    data object JapanNearby : DestinationCategory()
    data object JapanDistant : DestinationCategory()
    data object Overseas : DestinationCategory()

    companion object {
        private val nearbyPrefectures = setOf(
            "東京都", "神奈川県", "埼玉県", "千葉県",
            "山梨県", "群馬県", "栃木県"
        )

        fun pattern(address: Address): DestinationCategory {
            return when (address) {
                is Address.Domestic -> when {
                    nearbyPrefectures.contains(address.prefecture) -> JapanNearby
                    else -> JapanDistant
                }
                is Address.International -> Overseas
            }
        }
    }
}

+ // 決定の結果
+ data class ShippingEstimate(
+    val fee: Int,
+    val days: TimeCommitment
+ )
+    sealed class TimeCommitment {
+        data class Days(val count: Int) : TimeCommitment()
+        data object BestEffort : TimeCommitment()
+    }

✅ outputが2つ以上になっても、シンプルな構造を保つことが出来ます

3. VIP特典の追加: 分類ロジックの追加

あなたたちは、顧客離脱の防止のために、ヘビーユーザにはVIP会員として特典を享受してもらうことにしました。まずは、送料とお届け日時に対するメリットを提供することとします。

--- 冒頭の例に戻る ---

estimateShippingCostは今や、複数のロジックを「集約」し、より複雑なロジックを表現する豊かなモデルとなりました。
✅ VIP会員の定義やその特典内容について変更容易性があるため、あなたはエンジニアとして、それらのビジネス変化の要求にすぐに応えられることでしょう💪(VIP会員の定義の変更とVIP特典の変更の要求は、必ずしも同時に起こるものではないでしょう)

4. 配達遅延補償の追加: ポリシーの入れ子

更に実践に近づけた例にするため、ルールを追加しましょう。

あなたたちは、ネガティブな口コミの軽減を目的とし、配達遅延時にクーポンや補償を提供する施策を実行します。
アフターサービスサブドメインに関する業務として実装します。

  • 配達遅延補償ポリシー
条件 補償結果
▽遅延分類 ▽メンバーシップ ▽送料無料券 ▽商品券
遅延 一般 あり -
vip あり ¥2000
大幅遅延 一般 あり ¥1000
vip あり ¥2000
  • 遅延分類の知識
  • 遅延とは、1日以上4日以下の遅延を指す
  • 大幅遅延とは、5日以上の遅延を指す
CompensationPolicy.kt
package after_service.compensation

import shipping_estimate.estimateShippingCost
import shipping.destination.DestinationCategory.*
import shipping.membership.MembershipCategory.*


// shippingWorkflowが発行する想定のドメインイベント
data class OrderShipped(
    val orderId: String,
    val user: Customer
)
// このドメインイベントを元に、遅延補償ワークフローがトリガーする想定
data class CustomerReceived(
    val userId: String,
    val daysTaken: Int
)

// 判断結果
data class Compensation(
    val freeShippingCoupon: Boolean,
    val giftCardValue: Int
)
// 決定のロジック
fun compensationPolicy(
    order: CustomerReceived,
    orderShipped: OrderShipped
): Compensation {
    val membershipAtShipped = MembershipCategory.pattern(totalOrderCount(orderShipped.user.userId))
    val estimatedDays = estimateShippingCost(orderShipped.user.address, totalOrderCount(orderShipped.user.userId)).days

    val delayType = when (estimatedDays) {
        is TimeCommitment.Days -> DelayCategory.pattern(order.daysTaken, estimatedDays.count)
        is TimeCommitment.BestEffort -> DelayCategory.NoDelay
    }

    return when (delayType) {
        is DelayCategory.NoDelay -> Compensation(freeShippingCoupon = false, giftCardValue = 0)

        is DelayCategory.Delay -> when (membershipAtShipped) {
            is General -> Compensation(freeShippingCoupon = true, giftCardValue = 0)
            is VIP -> Compensation(freeShippingCoupon = true, giftCardValue = 2000)
        }

        is DelayCategory.SignificantDelay -> when (membershipAtShipped) {
            is General -> Compensation(freeShippingCoupon = true, giftCardValue = 1000)
            is VIP -> Compensation(freeShippingCoupon = true, giftCardValue = 2000)
        }
    }
}

// 知識による分類ロジック
sealed class DelayCategory {
    data object NoDelay : DelayCategory()
    data object Delay : DelayCategory()
    data object SignificantDelay : DelayCategory()

    companion object {
        fun pattern(daysTaken: Int, estimatedDays: Int): DelayCategory {
            val delayDays = daysTaken - estimatedDays

            return when {
                delayDays <= 0 -> NoDelay
                delayDays in 1..4 -> Delay
                else -> SignificantDelay
            }
        }
    }
}
final.kt
// 使用例のテスト
fun main() {
    val customer = Customer(
        userId = "new_user",
        country = "日本",
        address = Address.Domestic(prefecture = "東京都", detail = "千代田区")
    )
    val customer2 = Customer(
        userId = "vip_user",
        country = "日本",
        address = Address.Domestic(prefecture = "神奈川県", detail = "横浜")
    )
    val customer3 = Customer(
        userId = "user",
        country = "アメリカ",
        address = Address.International(detail = "カリフォルニア")
    )

    val orderShipped1 = OrderShipped(orderId = "order1", user = customer)
    val orderShipped2 = OrderShipped(orderId = "order2", user = customer2)
    val orderShipped3 = OrderShipped(orderId = "order3", user = customer3)

    val receivedOrder1 = CustomerReceived(userId = "user", daysTaken = 4)
    val receivedOrder2 = CustomerReceived(userId = "vip_user", daysTaken = 3)
    val receivedOrder3 = CustomerReceived(userId = "new_user", daysTaken = 6)

    println(compensationPolicy(receivedOrder1, orderShipped1))
    // Output: Compensation(freeShippingCoupon=true, giftCardValue=0)
    println(compensationPolicy(receivedOrder2, orderShipped2))
    // Output: Compensation(freeShippingCoupon=true, giftCardValue=2000)
    println(compensationPolicy(receivedOrder3, orderShipped3))
    // Output: Compensation(freeShippingCoupon=false, giftCardValue=0)
}

estimateShippingCostが分類のためのロジックで使われます。ポリシーの入れ子構造の中で再利用されています。

終わりに

今回紹介したパターンは、コード分割指針としてはF#やKotlin以外の言語でもおよそ適用可能なものと思います。
偶有的複雑性を抑えつつ、堅牢なコードにしていくためのひとつの参考になれば大変嬉しいです!

ログラスの開発組織では、Update Normalの心意気で日々試行錯誤しています。
バックエンド領域の方法論は、フロントエンドやインフラ領域に比べ変化が緩やかという印象です。
例えば、既存のDDDの文脈と関数型や型システムの文脈を交差させた部分での探索は、掘り甲斐のあるトピックの一つだと思っています。
少しでも似た興味をお持ちの方がいらっしゃいましたら、お話しできたらとても嬉しいです!

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

Discussion