Kotlin logo

Kotlin

A concise multiplatform language developed by JetBrains

Best Practices Kotlin

Case Study: Why Kakao Pay Chose Kotlin for Backend Development

This blog post is a JetBrains translation of the original post by katfun.joy, a backend developer at Kakao Pay. Kakao Pay leverages Kotlin with Spring for backend development across various services, including its insurance offerings. Check out Kakao Pay’s story to see how Kotlin helps it address the complex requirements of the insurance industry and enables the reliable development and operation of services.

Note: At Kakao Pay, each employees have their own nicknames to represent themselves. They address each other by calling their nickname instead of the actual name. Kuyho Chung, the writer of this post, uses ‘katfun.joy’ as his nickname.

katfun.joy, a backend developer at Kakao Pay, uses Kotlin to develop stable backend services. In this post, he introduces some of Kotlin’s most impressive features, such as value validation object creation, safe null handling, utility library creation using extension functions, and efficient unit tests, based on direct usage experience. If you are a backend developer interested in developing stable web services, it is recommended to read this.

💡 Reviewer’s One-Line Review

yun.cheese: Gain Kotlin tips that can be directly applied to actual service development through the vivid experiences of a developer deeply immersed in Kotlin’s charm!

noah.you: A compelling Kotlin use case to address the complex requirements of insurance! There are many good examples, so take a look!

ari.a: Why does Kakao Pay use Kotlin for the backend development of its services? For those curious, we recommend Katfun’s Kotlin use case!

Getting Started

Hello. I am Katfun, and I develop and operate services such as insurance comparison recommendations and car management services at Kakao Pay Insurance Market department.

At Kakao Pay, we use Kotlin for backend service development, including for our insurance services and various other services.

Before joining Kakao Pay, I had no experience with Kotlin, but as I used it, I became captivated by its convenience and various advantages. For example, here are some of Kotlin’s charms.

  • Using Kotlin allows us to make the services we operate more robust, stable, and efficient.
  • We easily gathered and managed content for specific domains or created libraries exclusively for our services.
  • Writing test codes with clear purposes and targets was also easily done through Kotlin.

After joining Kakao Pay and developing and operating several services with Kotlin, I wanted to introduce the charm of Kotlin that I experienced firsthand. Of the many great things about Kotlin, I have focused on four of its key charming aspects. This is a particularly good read for backend developers interested in developing stable web services, like me.

Creating objects with validated values at creation

When creating a VO(1) representing a value, there are cases where input values need to be transformed or validated. Although validation logic can be separated into a different class, if you have to call the validation logic separately each time, there is a possibility of skipping validation by mistake. Here is an example of concise code that guarantees validation using various Kotlin features.

In this VO representing a car number, we use Kotlin’s value class.

@JvmInline
value class CarNumber(val input: String)

A value class is a wrapper class for representing values in Kotlin. It can only have a single immutable field, and in the JVM environment, the class is unwrapped during compilation and replaced with its internal value. Thanks to this, primitive type values can be handled like objects, and it also solves the overhead problem of using wrapper classes. For more details, please refer to the Project Valhalla content in the References section.

Let’s assume the following rules for license plate numbers (car numbers).

  • Spaces aren’t allowed
  • The car number format must be one of the following
    • 12가1234
    • 123가1234
    • 서울1가1234
    • 서울12가1234

When creating a CarNumber instance, we want to remove the spaces and implement the following criteria:

  • The region name must be from the given list (Seoul, Gyeonggi, Daejeon, etc.), otherwise, an exception occurs.
  • If there is no region name, the first number must be 2–3 digits, and the last number must be 4 digits.
  • If there is a region name, the first number must be 1–2 digits, and the last number must be 4 digits.

This was added as validation in the factory method.

@JvmInline
value class CarNumber(val value: String) {
    companion object {
        private val CAR_NUMBER_REGEX = Regex("(\\d{2,3})([가-힣])(\\d{4})")
        private val OLD_CAR_NUMBER_REGEX = Regex("^([가-힣]{1,2})?(\\d{1,2})([가-힣])(\\d{4})\$")
        private val LOCATION_NAMES = setOf("서울", "부산", "대구", "인천", "광주", "대전", "울산", "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주")

        fun from(carNumber: String): CarNumber {
            return CarNumber(carNumber.removeSpaces())	// Remove spaces
        }
    }

    init {
        validateCarNumber(value)
    }

    private fun validateCarNumber(number: String) {
        val oldCarNumberMatch = OLD_CAR_NUMBER_REGEX.matchEntire(number)
        if (oldCarNumberMatch != null) {
            val (location, _, _) = oldCarNumberMatch.destructured
            require(location in LOCATION_NAMES) { "Unknown registration region." }	// Exception occurs if the region name of the old car number is not in the list
        } else {
            require(CAR_NUMBER_REGEX.matches(number)) { "Please check the car number format." }	// Exception occurs if it does not match the car number forma
        }
    }
}

Although the code may seem a bit complex, the steps are as follows.

  • Call the CarNumber.from() factory method to remove hyphens and spaces.
  • Invoke the logic during instance creation using the init { } block.
    • Verify whether it matches one of the two regular expressions (new car number, old car number).
    • If it is an old car number, check whether the region name is in the list.

The regular expressions (regex) and region names used for validation are written in the companion object and used as a singleton.

Does writing the code like this solve all the problems? Unfortunately, there is still the issue of the constructor being exposed.

val carNumber = CarNumber("123 가 4567") // Exception occurs because spaces are not removed.

If the constructor is called directly, the space removal process created in the factory method cannot be applied. Fortunately, it is possible to prevent the constructor from being called directly. You can add the private constructor access modifier to it.

value class CarNumber private constructor(val value: String) {

By adding private constructor, you can enforce the creation of CarNumber instances only through the factory method. However, this implementation may be somewhat unfriendly to CarNumber users. Users may attempt to create an instance using the constructor like CarNumber("123 가 4567"), but until they write the code, they won’t know that the constructor is blocked with private and that they need to use the factory method like CarNumber.from("123 가 4567") instead.

This can be resolved by overloading Kotlin’s invoke operator. Kotlin provides guidance that “function-type values can be called using the invoke operator”.

@JvmInline
value class CarNumber private constructor(val value: String) {
    companion object {
        // ...

        @JsonCreator
        fun from(carNumber: String): CarNumber {
            return CarNumber(carNumber.removeSpacesAndHyphens())
        }

        operator fun invoke(carNumber: String): CarNumber = from(carNumber)
    }
}

// Usage example
val carNumber = CarNumber("123 가 4567")	// Actually calls from instead of the constructor.

This allows users to create CarNumber instances as if they are directly calling the constructor, but internally, it hides the call to the factory method from.

In some cases, the factory method from can also be hidden with private. This makes it so users no longer need to worry about whether to use the constructor or the factory method when creating a CarNumber instance. At the same time, it also prevents the creation of CarNumber instances with broken consistency.

Finally, when using Jackson for serialization and deserialization, add the @JsonCreator annotation to the from factory method to use it. This completes a VO that satisfies all intended conditions. Below is the final completed CarNumber class.

@JvmInline
value class CarNumber private constructor(val value: String) {
    companion object {
        private val CAR_NUMBER_REGEX = Regex("(\\d{2,3})([가-힣])(\\d{4})")
        private val OLD_CAR_NUMBER_REGEX = Regex("^([가-힣]{1,2})?(\\d{1,2})([가-힣])(\\d{4})\$")
        private val LOCATION_NAMES =
            setOf("서울", "부산", "대구", "인천", "광주", "대전", "울산", "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주")

        @JsonCreator
        fun from(carNumber: String): CarNumber {
            return CarNumber(carNumber.removeSpacesAndHyphens())
        }

        operator fun invoke(carNumber: String): CarNumber = from(carNumber)
    }

    init {
        validateCarNumber(value)
    }

    private fun validateCarNumber(number: String) {
        val oldCarNumberMatch = OLD_CAR_NUMBER_REGEX.matchEntire(number)
        if (oldCarNumberMatch != null) {
            val (location, _, _) = oldCarNumberMatch.destructured
            require(location in LOCATION_NAMES) { "Unknown registration region." }
        } else {
            require(CAR_NUMBER_REGEX.matches(number)) { "Please check the car number format." }
        }
    }
}

When writing code like the example above in Kotlin, you can create VOs for various values and perform validation and transformation before instance creation. Writing the code this way allows you to delegate all roles and responsibilities related to car numbers to the VO. If policies related to car numbers change, you only need to check the CarNumber class. This naturally prevents unintended values from being used as car numbers and helps create stable services.

Example

Let’s take a closer look with a simple example. There is an API that receives input from users as shown below. If you declare the carNumber field in the request class as a CarNumber VO instead of a string, an exception will be immediately raised when the API is called with a car number that does not meet the conditions. There is no need to call separate validation logic, and if a CarNumber instance is successfully created, it guarantees that the car number value is valid.

@RestController
class CarController {
    @PostMapping("/car")
    fun carInformation(@RequestBody request: CarInformationRequest) {
        // ...
    }
}

data class CarInformationRequest(
    val carNumber: CarNumber
)

Ensuring null safety

Kotlin provides various ways to handle null safely. Here’s an example of one that helps with writing and understanding logic through immutability and smart casting(2)

There is a retryLogic method that resends an existing request. It performs the following actions:

  1. Explore the retryUseCase that matches the received category code.
    – Throw an exception if not found.
  2. Send a retry request using the found retryUseCase.

The code is as follows:

fun retryLogic(
    categoryCode: CategoryCode,
    transactionId: String,
    request: RetryRequest
) {
    val retryUseCase: UseCase? = activeUseCases().firstOrNull { it.type == categoryCode }
    requireNotNull(retryUseCase) { "The retry request is currently unavailable." }

    // Separate business logic

    return retryUseCase.getPrice(transactionId, request)
}

val retryUseCase is a value of type UseCase?. This indicates that the value could either be a UseCase or null. In Kotlin, unless you explicitly specify that the value’s type is nullable by adding a ? after it, the value cannot hold null by default.

Next, perform a null check on the received value. The commonly used method is to check for nullability using if.

if (retryUseCase == null) throw IllegalArgumentException("The retry request is currently unavailable.")

In Kotlin, you can write the exact same functionality using a contract called requireNotNull.

Even after checking for nullability, the problem is not completely resolved. If the value changes in the middle after the null check, then even if you previously checked for null on retryUseCase, you cannot be certain that it is still not null. In the above code, this is represented as the ‘separate business logic’ section.

The reason Kotlin is particularly strong in this area is because of Kotlin’s immutability. Values are declared using either val or var, and values declared with val are immutable. In other words, once a value is assigned, it does not change. This also applies when checking for null; once a value is confirmed to be not null, it is guaranteed to remain not null. In the example above, after requireNotNull(retryUseCase), the value is guaranteed not to be null.

This is also confirmed from a Kotlin language perspective through smart casting.

Through smart casting, the Kotlin compiler treats the type of retryUseCase as UseCase rather than UseCase? after requireNotNull(retryUseCase). The green-highlighted part in the image above represents this. Thanks to this, when writing or debugging code, you can confirm that a value that could be null is not null and proceed with the logic with confidence. This is, of course, a great help in the stable operation of services.

Creating a utility library using extension functions

Several utility codes are commonly used in the development of insurance services. In particular, there are many cases where something is manipulated for primitive type fields or strings. These were gathered to create a library called insurance-common.

Kotlin’s extension functions and object declarations can be used in the creation of such a library. Kotlin’s extension functions allow you to extend methods without separate design patterns or the inheritance of specific classes. Object declarations are used to contain content independent of the state of a specific instance. At the language level, they are declared as singletons, which is good for preventing the unnecessary creation of the same content multiple times.

For example, a method called maskingName is written to mask the corresponding code when a specific pattern is found in strings.

private val maskingNameRegex = Regex("(?i)Name=[^,)]++[,)]")

/**
* Masks numbers surrounded by "Number=" and "," or ")" in a string, except for the first digit
*/
fun maskingName(input: String): String {
    return input.replace(maskingNameRegex) { "${it.value.substring(0, 6)}*${it.value.last()}" }
}

// Usage example
val maskedValue = maskingName(userName)

If the above code is refactored using Kotlin’s extension functions, it can be modified as follows.

fun String.maskingName() = this.replace(maskingNameRegex) { "${it.value.substring(0, 6)}*${it.value.last()}" }

// Usage example
val maskedValue = userName.maskingName()

The masking method was extended to the string class. This method is unrelated to the state of a specific instance. In other words, declaring it as a singleton(3) and reusing it is advantageous for resource management. It can be used as a singleton by using a Kotlin object declaration.

object StringUtils {
    private val maskingNameRegex = Regex("(?i)Name=[^,)]++[,)]")

    /**
     * Masks strings surrounded by "Number=" and "," or ")", except for the first character
     */
    fun String.maskingName() = this.replace(maskingNameRegex) { "${it.value.substring(0, 6)}*${it.value.last()}" }
}

Examples of its usage can be confirmed through unit tests.

@DisplayName("Masks strings surrounded by Number= and , or ) except for the first character")
@Test
fun maskingName() {
    // given
    val name = "Kim Chun-sik"
    val text = "userName=$name, result=\"success\""
    val lowerText = "name=$name, result=\"success\""

    // when
    val result = text.maskingName()
    val lowerResult = lowerText.maskingName()

    // then
    val expectedMaskedResult = "Kim*"

    assertThat(result).isEqualTo("userName=$expectedMaskedResult, result=\"success\"")
    assertThat(lowerResult).isEqualTo("name=$expectedMaskedResult, result=\"success\"")
    }

In this way, functions used throughout the insurance service are being turned into libraries. This has reduced duplicate code across multiple ongoing projects and allowed their use without any performance degradation or other drawbacks. All of this is thanks to Kotlin’s extension functions and object declarations. Code managed this way is easy to read and maintain, greatly aiding in service operation.

Simple and efficient unit testing using data classes

When writing unit tests, data classes can be useful for setting up situations for testing. A data class in Kotlin is, as the name suggests, a class for representing data. Unlike regular classes, equals() and hashCode() are redefined, and other methods like copy() are automatically generated. Using data classes is useful when writing classes that represent data, such as DTOs(4).

This is an example of a DTO that needs to be tested.

data class UserInformation(
    val name: String,
    val age: Int,
    val birthDate: LocalDate,
    val address: String,
    val gender: Gender,
    val isDisplay: Boolean
) {
    enum class Gender {
        MALE,
        FEMALE;
    }

    init {
        require(age >= 18)
    }
}

The goal is to test whether age validation works. If the age is 18 or older, no exception should be thrown, and if it is under 18, an IllegalArgumentException should be thrown. Here’s one way to write the test:

class WhateverTest() {
    @Test
    fun `Throws IllegalArgumentException if age is under 18`() {
        assertThrows {
            val userInformation = UserInformation(
                name = "Chung Katfun",
                age = 17,
                birthDate = LocalDate.of(2022, 12, 19),
                address = "Kakao Pangyo Agit",
                gender = UserInformation.Gender.MALE,
                isDisplay = true
            )
        }
    }

    @Test
    fun `Does not throw exception if age is 18 or older`() {
        assertDoesNotThrow {
            val userInformation = UserInformation(
                name = "Chung Katfun",
                age = 18,
                birthDate = LocalDate.of(2022, 12, 19),
                address = "Kakao Pangyo Agit",
                gender = UserInformation.Gender.MALE,
                isDisplay = true
            )
        }
    }
}

Do you see the problem?

The target of the test is unclear.
Unnecessary code is repeated.

It is difficult to determine from the test code above which values in UserInformation contribute to the exception. Adding comments could partially resolve this, but if the DTO has dozens of fields, it would be hard to identify which fields have comments at a glance. Additionally, to create a UserInformation instance, appropriate values must be assigned to fields other than age.

To address this, the copy() function of the data class can be used. copy() has the following characteristics:

A completely identical data class instance is created. When compared with the original instance using equals(), they are considered equal.
When calling copy(), you can specify values for parameters. In this case, only the specified values of the corresponding parameters are copied.

Returning to the code above, let’s separate the common parts and revise it to make the test target clearer.

class WhateverTest() {
    @Test
    fun `Throws IllegalArgumentException if age is under 18`() {
        val invalidAge = 17
        assertThrows {
            val userInformation = successUserInformation.copy(age = invalidAge)
        }
    }

    @Test
    fun `Does not throw exception if age is 18 or older`() {
        val validAge = 18
        assertDoesNotThrow {
            val userInformation = successUserInformation.copy(age = validAge)
        }
    }

    private val successUserInformation = UserInformation(
        name = "Chung Katfun",
        age = 28,
        birthDate = LocalDate.of(2022, 12, 19),
        address = "Kakao Pangyo Agit",
        gender = UserInformation.Gender.MALE,
        isDisplay = true
    )
}

Here are the two versions of the code arranged side by side for comparison.

Do you notice the difference? Using copy():

  • Reduced repeated code.
  • Clearly highlighted the target affecting the test.

This lowers the barrier to writing tests and improves their readability. As a result, tests can better serve as documentation, and they can be used to write stable services. This is also valid when writing tests in a BDD(5) style, such as given-when-then or arrange-act-assert.

Conclusion

As someone responsible for the server side of our system, my priority is building services that are stable, readable, and scalable. Since I started using Kotlin two years ago at Kakao Pay, I’ve been able to meet these goals step by step while developing a variety of applications.

I wrote this blog post to share my experience in the hope that it helps others build and operate reliable backend systems. Whether you’re exploring Kotlin or aiming to create more stable backends, I hope you find it useful.

References

  1. Value Object – an object that represents a specific value (entity).
  2. This refers to a key feature of Kotlin where the compiler tracks type checks and explicit casting for immutable values and adds implicit casting when necessary.
  3. Singleton Pattern – a design pattern where only one instance of a specific class is created and used globally.
  4. Data Transfer Object – an object used for transferring data.
  5. Behaviour Driven Development – Describing the behavior of code using domain language when writing tests.

About Kuyho Chung (katfun.joy)

A server developer at Kakao Pay, I really enjoy solving difficulties and inconveniences through technology. I strive to write each line of code with a solid rationale.

image description