💡

Kotlinによる関数型アプローチを活用した型安全な注文確認フロー

2024/12/19に公開

かつ

こんにちは、ログラスでエンジニアをしております南部です。

「この注文、必要なバリデーションがされずにユーザーに注文確認が実行されてない?」
コードリーディングをしていて、そんなヒヤッとする経験はありませんでしょうか。
結果問題ないことがほとんどですが、私は経験したことがあります。

今回は、Kotlinの型システムを用いてこういった心配事を限りなくなくす実装パターンについて記述します。
直近、社内で「関数型ドメインモデリング ドメイン駆動設計とF#でソフトウェアの複雑さに立ち向かおう」という本を輪読したので、こちらで学んだエッセンスが盛り込まれています。
具体的にある処理をKotlinで実装しつつ、実装パターンを紹介していきます。

今回取り扱う処理

本記事で取り扱うのは、注文フローにおける「状態管理」の課題です。
具体的には以下のような流れを想定します。

処理の流れ

また、注文は以下のような情報で構成されています。

未検証(Unverified) な注文
ユーザーが入力したばかりの注文データであり、配送先住所や注文行などが妥当であるかどうかはまだ不明です。
(例:入力フォームから渡ってきた段階の注文)

検証済み(Verified) な注文
未検証の注文に対して住所の有効性や注文行の有無などをチェックし、有効であると確認した段階です。
ここで「この注文は正しい」という保証を得たうえで、次のステップに進みます。

計算済み(Calculated) な注文
検証済みの注文に対して金額計算などの後続処理を行った段階です。
この状態になって初めて、顧客に計算結果(確定した注文内容)を通知できるようになります。

この3つの状態は、本来「未検証 → 検証済み → 計算済み」という一方向の遷移をたどるべきです。
ところが、現場のコードではしばしば、未検証のまま計算処理が走ってしまったり、計算前の注文を顧客へ通知してしまったりするヒヤリとするケースが見受けられます。

次の章では、こうした「不適切な状態遷移」を防ぐ方法を考えるにあたり、まずは「不適切な状態遷移が起こりうる例」を見ていきます。
その後、型システムを用いた実装パターンを紹介し、手続き上のミスを原理的に防ぐアプローチを提示していきます。

不適切な状態遷移が起こりうる例

Order定義
data class Order(
    val id: String,
    val shippingAddress: String,
    val orderLines: Set<OrderLine>,
    val totalPrice: Int?,
) {
    fun verify(isAddressExist: (String) -> Boolean): Order {
        // 本来は未検証の状態でしか呼べないはずだが、
        // コンパイル時には強制されず、自由な順序で呼べる
        require(isAddressExist(shippingAddress)) { "配送先住所が存在しません" }
        return this
    }

    fun calculate(): Order {
        // 本来は検証済みであることが前提
        return this.copy(totalPrice = orderLines.sumOf { it.price })
    }
}

data class OrderLine(
    val id: String,
    val orderId: String,
    val price: Int
)
Order使用側
// 注文確認のためには恐らくそのための依存関係が必要
// 依存注入は使用側で行うため、使用側で定義している
private fun Order.sendOrderToCustomer(): Order {
    // 本来は計算済みであることが前提
    return this
}

fun main(order: Order, isAddressExist: (String) -> Boolean) {
    // 本来は「検証 → 計算 → 顧客通知」の順で呼ぶべきだが、
    // コンパイル時には順序が保証されないため、以下のような逆順の呼び出しも可能
    order
        .sendOrderToCustomer()
        .verify(isAddressExist)
        .calculate()
}

上記の例では、以下のような課題感があります。

  • 事前条件の保証がない
    verifyを呼ばずともcalculateやsendOrderToCustomerを実行できる

  • コンパイル時の不整合検出不可
    不正な呼び出しはコンパイル時に見つからず、テストや運用中に発覚する可能性がある

  • 状態がコード上で明確に表されていない
    「未検証」「検証済み」「計算済み」といった業務上重要な状態が明示されず、後続処理の前提条件があいまいになる

  • totalPriceをnullableで定義することになる:計算されていない状態をnullで表現することになるが、nullが計算前であることが暗黙知となる

もちろんテストを書くことで、バグを未然に防ぐことは可能です。しかし、コード上には必ず担保されるべき順序が現れていません。 今後機能が拡張されていくにあたり、テストをすり抜けて誤った順序で処理が実装されてしまう可能性もあります。

次の章では、型システムを用いて、上記の課題を解決するために処理の状態遷移を順番まで規定する書き方を示します。

型を用いて状態遷移の順番まで規定する例

以下は、状態を型で表し、コンパイラによって状態遷移を厳密に制約する実装例です。UnverifiedVerifiedCalculatedといった状態をsealed interfaceで定義し、それぞれの状態間遷移は特定の拡張関数を通じてのみ行えます。これによって、「検証されていないのに計算を実行する」「計算前の注文を顧客に通知する」といった不整合な処理が不可能になります。

Order定義
// 注文
sealed interface Order {
    val id: String
    val shippingAddress: String
    val orderLines: Set<OrderLine>

    data class Unverified(
        override val id: String,
        override val shippingAddress: String,
        override val orderLines: Set<OrderLine>,
    ) : Order

    data class Verified private constructor(
        override val id: String,
        override val shippingAddress: String,
        override val orderLines: Set<OrderLine>,
    ) : Order {
        companion object {
            fun Unverified.verify(isAddressExist: (String) -> Boolean): Verified {
                require(isAddressExist(shippingAddress)) { "配送先住所が存在しません" }
                return Verified(id, shippingAddress, orderLines)
            }
        }
    }

    data class Calculated private constructor(
        override val id: String,
        override val shippingAddress: String,
        override val orderLines: Set<OrderLine>,
        val totalPrice: Int,
    ) : Order {
        companion object {
            fun Verified.calculate(): Calculated {
                return Calculated(id, shippingAddress, orderLines, orderLines.sumOf { it.price })
            }
        }
    }
}

// 注文明細行
data class OrderLine(
    val id: String,
    val orderId: String,
    val price: Int
)
Order使用側
import Order.Calculated.Companion.calculate
import Order.Verified.Companion.verify

// 注文確認のためには恐らくそのための依存関係が必要
// 依存注入は使用側で行うため、使用側で定義している
private fun Order.Calculated.sendOrderToCustomer() : Order.Calculated {
    // 顧客に注文確認
    return this
}

fun main(order: Order.Unverified, isAddressExist: (String) -> Boolean) {
    // 順番を変えるとコンパイルエラーになるので、変えられない
    order
        .verify(isAddressExist)
        .calculate()
        .sendOrderToCustomer()
}

この実装パターンのメリット

  • コンパイル時保証
    未検証のUnverified注文を直接Calculatedにできない、Calculatedでなければ顧客への通知が呼べない、などの手続き上の制約をコンパイラが保証します。
    これにより、バリデーションをスキップしたり計算抜きで通知したりといった誤りはコンパイル時に弾かれ、実行時に初めて発覚するといったリスクが減ります。

  • コードの意図が明確
    「検証はUnverifiedからVerifiedへの変換でのみ可能」「計算はVerifiedからCalculatedへの一方向遷移」というルールが、型定義そのものに刻まれます。コメントやドキュメンテーションに頼らず、コードを読むだけで業務フローを理解しやすくなります。

  • アプリ内データの安全性担保
    実装者はverifycalculateを通してしかCalculatedは生成できず、万が一にも未処理のデータをアプリケーション内で取り扱うことはありません。

  • 保守性・拡張性の向上
    新たな手続きが加わった場合にも、状態と状態遷移を型で管理していれば、変更点が明確で意図しないバグが入り込みにくくなります。

この実装パターンにおける特徴的な実装

この実装パターンにおいて、Kotlinの良さを活かした特徴的な実装をしているので取り上げます。
Verifiedだけ改めて抽出すると、以下のような実装になっています。

    data class Verified private constructor(
        override val id: String,
        override val shippingAddress: String,
        override val orderLines: Set<OrderLine>,
    ) : Order {
        companion object {
            fun Unverified.verify(isAddressExist: (String) -> Boolean): Verified {
                require(isAddressExist(shippingAddress)) { "配送先住所が存在しません" }
                return Verified(id, shippingAddress, orderLines)
            }
        }
    }

以下の2点が特徴的な部分です。

  • Verifiedのコンストラクタがprivateになっている
  • Verifiedを取得する関数は、companion object内にUnverifiedの拡張関数としてのみ定義されている

このような実装にすることで、実装者はUnverifiedに対してverify関数を実行することでしかVerifiedを取得することができなくなります。
Unverifiedを通さずにVerifiedをインスタンス化できないので、アプリケーション内に実際には検証されていないが型はVerifiedのようになっている危険なインスタンスが物理的に存在し得なくなります。
この書き方が、上述したメリットのアプリ内データの安全性担保につながっています。

関数型的アプローチへの発展

前章の「型を用いて状態遷移まで規定する」実装パターンでは、UnverifiedVerifiedCalculated という状態遷移を型システムで明確にし、誤った手順でのメソッド呼び出しをコンパイル時に防ぐことに成功しました。これによって、業務ロジック上の不変条件(検証前には計算できない、計算前には顧客通知できない)を強固に保証できます。

この段階で、状態管理そのものはかなり「関数型プログラミング的」なアプローチに近づきました。

  • 型による安全性向上
  • 不正な状態遷移のコンパイル時検出
  • 不変オブジェクトによる純粋な状態変換

しかし、まだ「バリデーションに失敗したらどうする?」「計算できない場合は?」といった失敗ケースの取り扱いが、requireによる例外任せになっています。関数型プログラミング的な考え方では、こうしたエラーケースをResult型などで明示的に表すことで、関数型スタイルの恩恵(合成可能なエラーハンドリング、テスト容易性、予測可能な制御フロー)をさらに享受できます。

次の章では、kotlin-resultを用いて、状態遷移の安全性を維持しつつ、検証や計算といった処理の失敗ケースを、戻り値として返す「関数型的なエラーハンドリング」へと拡張する方法を紹介します。これにより、正しい状態遷移とエラーハンドリングが一体となった、より関数型的なアプローチへと進化させることができます。

kotlin-resultを用いたより関数型のアプローチ

Order定義
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result

// 注文
sealed interface Order {
    val id: String
    val shippingAddress: String
    val orderLines: Set<OrderLine>

    data class Unverified(
        override val id: String,
        override val shippingAddress: String,
        override val orderLines: Set<OrderLine>,
    ) : Order

    data class Verified private constructor(
        override val id: String,
        override val shippingAddress: String,
        override val orderLines: Set<OrderLine>,
    ) : Order {
        companion object {
            fun Unverified.verify(isAddressExist: (String) -> Boolean): Result<Verified, OrderError.VerifyError> {
                return if (isAddressExist(shippingAddress)) {
                    Ok(Verified(id, shippingAddress, orderLines))
                } else {
                    Err(OrderError.VerifyError.AddressNotExist("配送先住所が存在しません"))
                }
            }
        }
    }

    data class Calculated private constructor(
        override val id: String,
        override val shippingAddress: String,
        override val orderLines: Set<OrderLine>,
        val totalPrice: Int,
    ) : Order {
        companion object {
            fun Verified.calculate(): Result<Calculated, OrderError.CalculationError> {
                return if (orderLines.isEmpty()) {
                    Err(OrderError.CalculationError.Calculation("注文明細がありません"))
                } else {
                    Ok(Calculated(id, shippingAddress, orderLines, orderLines.sumOf { it.price }))
                }
            }
        }
    }
}


// 注文明細行
data class OrderLine(
    val id: String,
    val orderId: String,
    val price: Int
)

sealed interface OrderError {
    val message: String
    sealed interface VerifyError : OrderError {
        data class AddressNotExist(override val message: String) : VerifyError
    }
    sealed interface CalculationError : OrderError {
        data class Calculation(override val message: String): CalculationError
    }
}

Order使用側
import Order.Calculated.Companion.calculate
import Order.Verified.Companion.verify
import com.github.michaelbull.result.andThen
import com.github.michaelbull.result.mapBoth

// 注文確認のためには恐らくそのための依存関係が必要
// 依存注入は使用側で行うため、使用側で定義している
private fun Order.Calculated.sendOrderToCustomer() : Order.Calculated {
    // 顧客に注文確認
    return this
}

fun main(order: Order.Unverified, isAddressExist: (String) -> Boolean) {
    // 順番を変えるとコンパイルエラーになる
     order
        .verify(isAddressExist)
        .andThen { verified -> verified.calculate()  }
        .mapBoth(
            { calculated -> calculated.sendOrderToCustomer() },
            { error -> throw RuntimeException(error.message) }
        )
}

ポイント

  • ConfirmOrderErrorインターフェースと具体的なエラー型
    ConfirmOrderErrorインターフェースと、それを実装したAddressNotExistCalculationといったエラー型を用いることで、エラーの種類を明示的に型で表現しています。
  • Result型の活用
    verifycalculate関数がResult<T, ConfirmOrderError>を返すことで、失敗時はErr(ConfirmOrderError)を返し、呼び出し側はmapBothなどを用いて成功パス・失敗パスを明確にハンドリングできます。
  • mapBothの使用
    コード例内でmapBothを使用しているため、calculated(成功パス)ではそのままsendOrderToCustomerを呼び、error(失敗パス)ではRuntimeExceptionを投げる、という明確な分岐ロジックを記述できます。
  • エラーハンドリングの向上
    このようなエラーハンドリングは、requireによる強制的な例外発生よりも、状態の不整合が起きた際の流れが明示的になり、なぜ処理が失敗したのかが型とメッセージで明確化されます。その結果、将来的に特定のエラー時にリトライしたりユーザーへ特定のエラーメッセージを返したりする拡張が容易 といった利点をもたらします。

kotlin-resultについては弊社のテックブログで取り上げているものがあるので、気になる方はそちらもご覧ください。
https://zenn.dev/loglass/articles/try-using-kotlin-result

まとめ

状態を型で厳密に表すことと、Result型を活用した明示的なエラーハンドリングを組み合わせることで、Kotlinで関数型プログラミングのエッセンスを生かした堅牢な実装が可能になります。これにより、業務ロジックにおける手続き順序違反やエラー処理の抜け漏れを構造的に防ぎ、開発・保守の品質を向上させることができます。
業務ロジックをコードに落とす上でどなたかの参考になれば幸いです。
弊社ではこういったバグを生まないコードを書く工夫を模索していますので、コメントで皆さんの工夫を教えていただけると嬉しいです。

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

Discussion