コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

ビジネスルールを型で表現するリファクタリング手法

こちらは「コドモン Advent Calendar 2024」の 3日目の記事です

qiita.com

エンジニアの上代です。

今回ご紹介するのは、請求に関連するシステムで実施したリファクタリングについてです。このプロジェクトでは、サーバーサイドをKotlinで開発しており、請求の生成、支払い処理、キャンセル対応といったビジネスロジックを扱っています。具体的には、ドメインロジックやAPI設計をKotlinで実装し、型安全性や開発効率を活かしながらビジネス要件を実現しています。

プロジェクトが進むにつれて、さまざまな状態や処理が一つのクラスに集約され、ビジネスロジックが複雑化していました。これにより、コードの可読性や保守性が低下し、新たな状態を追加するたびに既存のロジックに影響を与えるリスクが増していたため、ビジネスルールを型で明確に表現する設計に変更するリファクタリングを行いました。

具体的には、Kotlinのsealed interfaceと状態ごとに分離されたクラスを活用して、各状態を独立したクラスとして定義することで、ロジックを整理しました。このアプローチによって得られた保守性や可読性の向上について、コード例とともに解説していきます。似たような課題に直面している方の参考になれば幸いです。

背景

リファクタリング前のBillクラスは、請求に関わる様々な状態(キャンセル、返金、支払い完了など)を一つのクラスで管理していました。以下のように、状態ごとのプロパティ(cancellationやrefund)をBillクラスに直接持たせる設計だったため、複雑なビジネスロジックが混在し、状態ごとの管理が難しくなっていました。

class Bill private constructor(
    val id: Id,
    // 多くのプロパティ
    val cancellation: Cancellation? = null,
    val refund: Refund? = null,
) {
    companion object {
        fun create(amount: Int, taxRate: Int, discountRate: Int): Bill {
            return Bill(amount, taxRate, discountRate)
        }
    }
    fun cancel(
        canceledBy: EmployeeId
    ): Either<BillDomainError, Bill> = either {
        ensure(!isPayed()) {
            BillDomainError("This billing has already been paid.")
        }
        ensure(!isCanceled()) {
            BillDomainError("This billing has already been canceled.")
        }
        // キャンセル状態の請求を返す
        Bill(
            childSpotBillId,
            // 他のプロパティ
            Cancellation.createFrom(canceledBy)
        )
    }
}

usecaseでの使用例

このBillクラスをusecaseで利用する際は、次のようにcancelメソッドでキャンセル状態を生成し、それをリポジトリに保存していました。

val  canceledBill = bill.cancel(input.ofEmployeeId())
      .mapLeft { CancelUseCaseError(it.message, ErrorCode.InvalidState) }
      .bind()

childSpotBillingRepository.saveCancellation(canceledChildSpotBilling)

この設計には以下の問題がありました。

  1. 条件分岐の増加と可読性の低下
    cancelメソッド内でensureを用いたガード節(guard clauses)を多用しています。これにより、メソッドの冒頭で状態チェックを行うことはできますが、状態が増えるにつれてガード節が増加し、可読性が低下します。また、他のメソッドでも同様の状態チェックが必要な場合、同じようなガード節を繰り返し記述する必要があり、コードの重複やミスの原因となります。

  2. 状態管理の煩雑さ
    Billクラスが複数の状態を一元的に管理しているため、各状態に対応するプロパティがnullかどうかで状態を判定しています。これは、コードの意図を読み取りにくくし、予期せぬ状態遷移を引き起こすリスクを高めます。特に、cancellationrefundnullであるかどうかを毎回チェックする必要があり、状態管理が煩雑になっていました。

  3. エラーハンドリングの複雑化
    ガード節で状態をチェックしてエラーを生成していますが、状態が増えるにつれてエラーハンドリングも複雑になります。各メソッドで同様のエラーチェックを実装する必要があり、エラーメッセージの一貫性を保つのが難しくなります。

  4. 拡張性の低下
    新しい状態やビジネスルールが追加されるたびに、既存のBillクラスにプロパティやガード節を追加する必要があります。これにより、クラスが肥大化し、メンテナンス性が低下します。また、状態の追加に伴う既存ロジックへの影響範囲が広く、バグを生む可能性も高まります。

リファクタリング後

改善後は、Billクラスをsealed interfaceとして再定義し、各状態を専用のクラスで実装することで、状態ごとに型を分割しました。この構造により、異なる状態に対する処理が各クラスに分散され、コードが直感的で保守しやすくなっています。

以下は、Billインターフェースの定義です。ここでは、ビジネスロジックに基づいてBillの各状態を適切にインスタンス化するために、fromメソッドを用意しています。

sealed interface Bill {
    val id: String
    val billNumber: BillNumber

    companion object {
        fun from(
            id: String,
            billingNumber: String,
            cancellation: Cancellation? = null,
            refund: Refund? = null,
            payment: Payment? = null
        ): Either<BillDomainError, Bill> = either {
            when {
                cancellation != null -> CanceledBill(id, billingNumber, cancellation)
                refund != null -> RefundedBill(id, billingNumber, refund)
                payment != null -> PaidBill(id, billingNumber, payment)
                else -> BilledBill(id, billingNumber)
            }
        }
    }
}

このfromメソッドは、cancellationやrefundといったプロパティの値に基づいて、Billの適切な状態を選択・生成する役割を持っています。これにより、インスタンス生成時の状態判別が明確になり、呼び出し側のコードがシンプルに保たれます。

例えば、キャンセル済みの請求を表すCanceledBillや支払い済みのPaidBillなどは、以下のように専用クラスとして定義しています。これにより、各状態に応じたロジックを分離し、意図が明確な設計を実現しています。

class CanceledBill private constructor(
    override val id: String,
    override val billNumber: BillNumber,
    val cancellation: Cancellation
) : Bill

class BilledBill private constructor(
    override val id: String,
    override val billNumber: BillNumber
) : Bill {
    fun cancel(cancellation: Cancellation): Either<BillDomainError, CanceledBill> = either {
        CanceledBill(
            id = this.id,
            billNumber = this.billNumber,
            cancellation = cancellation
        )
    }
}

class PaidBill private constructor(
    override val id: String,
    override val billNumber: BillNumber,
    val payment: Payment
) : Bill

class RefundedBill private constructor(
    override val id: String,
    override val billNumber: BillNumber,
    val refund: Refund
) : Bill

リファクタリング後のusecase

リファクタリング後のusecaseでは、whenステートメントを用いることで、請求の状態ごとに異なる処理を行うことが可能です。状態が明示されているため、エラーや条件分岐が明確になり、可読性が向上しました。

val canceledChildSpotBilling: CanceledChildSpotBill = when (bill) {
    is CanceledBill -> {
        CancelUseCaseError("This bill has already been canceled.", ErrorCode.InvalidInput).left().bind()
    }
    is BilledBill -> {
        val canceledBill = bill.cancel(id)
        billRepository.saveCancellation(canceledBill)
        canceledBill
    }
    is PaidBill -> {
        CancelUseCaseError("This bill has already been paid.", ErrorCode.InvalidInput).left().bind()
    }
    is RefundedBill -> {
        CancelUseCaseError("This bill has already been refunded.", ErrorCode.InvalidInput).left().bind()
    }
}

成果

リファクタリングにより、以下のようなメリットが得られました。

  1. コードの明確化と可読性の向上
    各状態が専用のクラスとして分離されたことで、コードがシンプルになり、各クラスの責務が明確になりました。これにより、コードの可読性が大幅に向上し、他のエンジニアが状態ごとの処理を理解しやすくなりました。

  2. エラーハンドリングの一貫性と簡略化
    状態ごとに異なるエラーハンドリングが適切に分離され、ガード節(guard clauses)による条件分岐が各クラス内で完結するようになりました。これにより、エラーハンドリングの一貫性が保たれ、コードの重複が削減されました。

  3. メンテナンス性の向上
    状態ごとのクラス分離により、特定の状態に関するロジックを修正する際に他の状態に影響を与えにくくなりました。新たな状態やビジネスルールの追加も容易になり、将来的なメンテナンスがしやすくなりました。

  4. 拡張性の向上
    新しい状態が必要な場合でも、sealed interfaceに新しいクラスを追加するだけで済むため、既存のコードへの影響を最小限に抑えた形で拡張が可能です。これにより、ビジネス要件の変更に対しても柔軟に対応できます。

今回紹介したような方法がリファクタリングを考える際の参考にしてもらえたら嬉しいです。

参考文献

  • Scott Wlaschin(著)、関数型ドメインモデリング ── 関数型プログラミングで解きほぐす複雑なソフトウェア設計、アスキードワンゴ、2024年。

ビジネスロジックを型で表現するアプローチをとてもわかりやすく解説しており、リファクタリングの際の参考としました。