LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

This post is also available in the following languages. Japanese

Improving code quality - Session 9: Retrace your steps

Hello, I'm Munetoshi Ishikawa, a mobile client developer for the LINE messaging app.

This article is the latest installment of our weekly series "Improving code quality". For more information about the Weekly Report, please see the first article.

Retrace your steps

When using I/O such as networks or file systems, you need to convert between data representations in I/O and data representations in code. A typical example is the conversion between externally defined data like Interface Description Language (IDL) or database schemas and model classes defined in code. When using values that mean "state" or "type", you might use enumerations in code.

The following code uses a Map to convert between database values and enumerators.

enum class AccountType { FREE, PREMIUM, ULTIMATE }

val DB_VALUE_TO_ACCOUNT_TYPE_MAP: Map<Int, AccountType> = mapOf( // or SparseArray
    0 to AccountType.FREE,
    1 to AccountType.PREMIUM,
    2 to AccountType.ULTIMATE
)

val ACCOUNT_TYPE_TO_DB_VALUE_MAP: Map<AccountType, Int> = mapOf( // or EnumMap
    AccountType.FREE to 0,
    AccountType.PREMIUM to 1,
    AccountType.ULTIMATE to 2
)

Is there any problem with this code?

Two parallel roads

This code defines the conversion from database values to enumerations and vice versa separately, leading to the following two issues:

  1. You have to update both Map when specifications change.
  2. You can't guarantee that the two conversions correspond to each other.

To solve this problem, it's better to derive the reverse conversion from the other conversion. Especially if you can use enumerator properties or when/switch expressions, you can use them instead of Map to ensure that all enumerators are covered.

Implementation example 1: Enumerator properties & reverse conversion map

By using enumerator properties, you can ensure that all enumerators have corresponding database values. In Kotlin, you can use the associateBy function to create a Map from dbValue to AccountType. In many other languages, you can achieve the same by writing functions like map or loops over enumerators.

enum class AccountType(val dbValue: Int) {
    FREE(0),
    PREMIUM(1),
    ULTIMATE(2);
    
    companion object {
        val DB_VALUE_TO_TYPE_MAP: Map<Int, AccountType> =
            AccountType.entries.associateBy(AccountType::dbValue)
    }
}

The following code shows an example of performing the conversion.

accountType.dbValue // AccountType to DB value
AccountType.DB_VALUE_TO_TYPE_MAP[dbValue] // DB value to AccountType

This implementation is simple, but if AccountType is used widely, the dbValue will also be visible widely, which can be problematic. Additionally, if you need to convert between multiple data representations, you will need to define multiple values corresponding to dbValue, which can lead to confusion. The following implementation of AccountType has values for multiple conversions, but it is easy to imagine that the class definition will quickly become bloated if the number of conversions increases. In that case, consider applying the next "Implementation example 2".

enum class AccountType(
    val dbValue: Int,
    val fooApiJsonValue: String,
    val barApiJsonValue: String
) {
    ...
}

Implementation example 2: Implement conversion within individual layers & reverse conversion map

If the code using the conversion is confined within a function, layer, or scope, you can define the conversion within that range. The following code defines the conversion between serverValue and AccountType within the scope of AccountTypeNetworkClient. Here, the conversion from AccountType to serverValue uses a when expression to ensure that all enumerators are covered.

class AccountTypeNetworkClient {
    /* snip */

    companion object {    
        private fun AccountType.toServerValue(): String = when (this) {
            AccountType.FREE -> "free"
            AccountType.PREMIUM -> "premium"
            AccountType.ULTIMATE -> "ultimate"
        }

        private val SERVER_VALUE_TO_TYPE_MAP: Map<String, AccountType> =
            AccountType.entries.associateBy { it.toServerValue() }
    }
}

Another option is to define classes like Mapper or Converter individually. Some platforms make it easy to implement such classes. However, even when implementing Mapper or Converter, you should define both the forward and reverse conversion in the same file or class.

Unit tests to ensure injection

Both of the above implementation examples ensure that all enumerators have corresponding values (the enumerators are the domain). However, they do not guarantee that the values are unique (injective). To ensure that the values are unique, you should write tests like the following:

assertEquals(DB_VALUE_TO_TYPE_MAP.size, AccountType.entries.size)

Summary

When performing bidirectional conversion, it's preferable to derive one conversion logic from the other.

Keywords: type conversion, bidirectional, single source of truth