Kotlinのsealed classを使ってif文を取り除き、コードをシンプルにする

はじめに

皆様こんにちは、ソーシャル経済メディア「NewsPicks」(Media Infrastructureチーム)エンジニアの北見です。

弊社では Server Side Kotlin を採用しており、昔に書かれた一部のコードは Java ですが、基本的に新規コードは Kotlin で書いており、既存の Java コードも Kotlin 化を推し進めています。

tech.uzabase.com

tech.uzabase.com

今回は Kotlinsealed class を使って、コードをシンプルにする例をご紹介します。

kotlinlang.org

微妙に異なるけど殆ど同じ処理を共通化

例えば、クレジットカードとキャリア決済の2通りで商品を購入するユースケースを考えてみます。

class PurchaseService(
    val productRepository: ProductRepository,
    val purchaseRepository: PurchaseRepository,
    val purchaseHistoryRepository: PurchaseHistoryRepository
){
    fun purchaseWithCreditCard(productId: Int, creditCardNum: Int) {
        val product = productRepository.findProduct(productId)
        if (product == null){
            throw ResourceNotFoundException("Product not found")
        }

        val purchaseResult = purchaseRepository.purchaseWithCreditCard(productId, creditCardNum)
        purchaseHistoryRepository.save(productId, purchaseResult.id)
    }
    
    fun purchaseWithCarrier(productId: Int, carrier: CarrierCashInfo) {
        val product = productRepository.findProduct(productId)
        if (product == null){
            throw ResourceNotFoundException("Product not found")
        }

        val purchaseResult = purchaseRepository.purchaseWithCarrier(productId, carrier)
        purchaseHistoryRepository.save(productId, purchaseResult.id)
    }
}

2つのメソッドは

  1. productId の商品があれば処理を続行、なければエラー

  2. それぞれの方法で購入

  3. 購入履歴を保存

という処理を行っていますが、1. と 3. は全く同じ処理です。

なんとか同一メソッドで表現できないでしょうか?

class PurchaseService (
    val productRepository: ProductRepository,
    val purchaseRepository: PurchaseRepository,
    val purchaseHistoryRepository: PurchaseHistoryRepository
){
    fun purchase(productId: Int, creditCardSeq: Int?, carrier: CarrierCashInfo?) {
        val product = productRepository.findProduct(productId)
        if (product == null){
            throw ResourceNotFoundException("Product not found")
        }

        if (creditCardSeq == null && carrierCashInfo == null){
             throw InvalidArgumentException("xxxxx")
        }
        var purchaseId = 0
        if (creditCardSeq != null){
            purchaseId = purchaseRepository.purchaseWithCreditCard(productId, creditCardNum!!).id
        } else if (carrier != null){
            purchaseId = purchaseRepository.purchaseWithCarrier(productId, carrier!!).id
        }
        
        purchaseHistoryRepository.save(productId, purchaseId)
    }
}

うーん、強引に同じメソッドに突っ込んでみましたが、分かりにくいですね。引数によって条件が分かれてしまい、追いづらいです。

このように、微妙に異なるけど殆ど同じ処理を共通化する とき、sealed class を使ってとてもスッキリさせることができます。

この場合、まずクレジット決済もキャリア決済も共通のプロパティを sealed class に、固有のプロパティを継承先に定義します。

sealed class PurchaseRequest {
    abstract val productId: Int
}

data class PurchaseWithCreditCardRequest (
    override val productId: Int,
    val creditCardNum: Int
) :  PurchaseRequest()


data class PurchaseWithCarrierRequest (
    override val productId: Int,
    val carrier: CarrierCashInfo
) : PurchaseRequest()

これを when ~ is をつかって条件分岐を表現してあげると

class PurchaseService (
    val productRepository: ProductRepository,
    val purchaseRepository: PurchaseRepository,
    val purchaseHistoryRepository: PurchaseHistoryRepository
){
    fun purchase(request: PurchaseRequest){
        val product = productRepository.findProduct(request.productId)
        if (product == null){
            throw ResourceNotFoundException("Product not found")
        }

        // requestの型に応じた条件分岐
        val result = when (request) {
             is PurchaseWithCreditCardRequest -> purchaseRepository.purchaseWithCreditCard(productId, request.creditCardNum)
             is PurchaseWithCarrierRequest -> purchaseWithCarrier.purchaseWithCarrier(productId, request.carrier)
        }
        purchaseHistoryRepository.save(productId, result.id)
    }

どうでしょうか、明らかに見やすくなったのではないでしょうか?

sealed classのサブクラスはコンパイル時に把握されており、そのsealed classが定義されているモジュールやパッケージの外で他のサブクラスが出現することはありません。

そのため今回ご紹介したサンプルコードの when ~ is の条件分岐で対応漏れが出ることはなく、保守性も上々です。

さいごに

最近 sealed class を知った私が、自分なりにその魅力を言語化すると

微妙に異なるけど殆ど同じ処理を、if文を使わずシンプルに記述できる

ことにあるのではないかと思っています。

同様のケースで悩まれている方の力になれたのであれば幸いです。

ここまで読んで下さり、ありがとうございました。

Page top