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).

Improving code quality - Session 17: A castle built on sand

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.

A castle built on sand

The following UserProfileViewData is a UI model for displaying "User Profile".

class UserProfileViewData private constructor(
    val userName: String,
    val emailAddress: String,
    val profileImageUri: Uri?,
    val optionalStatusMessage: String?
)

To create an instance of this, a builder like the one below is provided.

Here, apply is a function that returns the receiver after calling the argument. In other words, apply { userName = value } assigns value to userName and then returns this. Also, checkNotNull is a function that throws an IllegalStateException if the argument is null. If the argument is not null, it returns the argument as is.

class UserProfileViewData private constructor(
    ...
) {
    class Builder {
        private var userName: String? = null
        private var emailAddress: String? = null
        private var profileImageUri: Uri? = null
        private var optionalStatusMessage: String? = null

        fun userName(value: String): Builder =
            apply { userName = value }

        fun emailAddress(value: String): Builder =
            apply { emailAddress = value }

        fun profileImageUri(value: Uri): Builder =
            apply { profileImageUri = value }

        fun optionalStatusMessage(value: String): Builder =
            apply { optionalStatusMessage = value }

        fun build(): UserProfileViewData {
            val nonNullUserName = checkNotNull(userName)
            val nonNullEmailAddress = checkNotNull(emailAddress)
            return UserProfileViewData(nonNullUserName, nonNullEmailAddress, profileImageUri, optionalStatusMessage)
        }
    }
}

Are there any issues with this code?

Building on a solid foundation

Unless there's a special reason, it's often better to use constructors or factory functions instead of the builder pattern.

Using constructors or factory functions makes it easier to avoid bugs where required parameters are forgotten. In this case, using the constructor of UserProfileViewData directly is sufficient. In the previous Builder, userName and emailAddress were required, but forgetting to provide them wouldn't result in a compile-time error, only a runtime error. It's better to catch errors at compile time rather than runtime for more robust code.

However, sometimes you have to use the builder pattern due to library or platform constraints (like O/R mappers). There are also situations where the builder pattern is necessary:

  1. When there are few required parameters, and many parameters have default values.
  2. When you need to pass the "in-progress" state to other classes or functions.
  3. When you want to define "terminal operations" for decorators, etc.

However, points 1 and 2 can sometimes be handled in other ways.

1: When many parameters have default values

If many parameters are optional, some programming languages allow you to use default arguments. If the patterns for providing parameters are limited, overloading the constructor is another option.

class UserProfileViewData(
    val userName: String,
    val emailAddress: String,
    val profileImageUri: Uri? = null,
    val optionalStatusMessage: String? = null
)

In programming languages that don't support default arguments, the builder pattern is an option. In that case, you can make the code more robust by passing the required parameters to the builder's constructor. In the following Builder, the required userName and emailAddress are received as parameters in the builder's constructor.

class Builder(
    private val userName: String,
    private val emailAddress: String
) {
    private var profileImageUri: Uri? = null
    private var optionalStatusMessage: String? = null

    ...

    fun build(): UserProfileViewData {
        return UserProfileViewData(userName, emailAddress, profileImageUri, optionalStatusMessage)
    }
}

2: Handling the "in-progress" state

There are situations where you might want to pass the "in-progress" state to other functions, like this:

fun caller() {
    val builder = Builder()
        ...

    setStatusMessage(builder)

    val viewData = builder.build()
    ...
}

fun setStatusMessage(builder: Builder) {
    ...
    val statusMessage = ...

    builder.optionalStatusMessage(statusMessage)
}

However, the builder argument in setStatusMessage behaves like an out parameter. Generally, to make the code more readable, it's better to use return values instead of out parameters. By using return values, you can sometimes replace the builder pattern with constructors or factory functions, like this:

fun caller() {
    val viewData = UserProfileViewData(
        ...,
        getStatusMessage(...)
    )
}

fun getStatusMessage(...): String {
    ...
    return statusMessage
}

If the logic for constructing an instance is like a pipeline, you can define different types for each stage of construction to eliminate invalid states. In the following code, the types are separated into UserAccountModel and UserProfileViewComponent before and after assigning profileImageUri.

class UserAccountModel(val userName: String, val emailAddress: String)
class UserProfileViewComponent(
    val accountModel: UserAccountModel,
    val profileImageUri: Uri?
)

fun toUserProfileViewComponent(
    accountModel: UserAccountModel
): UserProfileViewComponent { ... }

3: Creating terminal operations

When the following conditions are met, using something similar to the builder pattern can work well:

  • There are operations that can be applied any number of times, in any order.
  • After a "terminal operation", the above operations are prohibited.

Typically, this applies when adding terminal operations to the decorator pattern. The following code is an example of using this pattern for image editing.

val profileImageBitmap = loadModifiableImage(uri)
    .crop(CropType.ROUND)
    .fitIn(heightPx, widthPx)
    .colorFilter(Filter.DARK)
    .createBitmap()

This can be more readable than a pure decorator pattern, like this:

val profileImage = ColorFilter(
    FitIn(
        Crop(
            loadModifiableImage(uri),
            CropType.ROUND
        ),
        heightPx,
        widthPx
    ),
    Filter.DARK
).getBitmap()

Summary

Consider using constructors or factory functions instead of the builder pattern.

Keywords: builder pattern, optional properties, immutability

List of articles for the "Improving code quality" series