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:
- When there are few required parameters, and many parameters have default values.
- When you need to pass the "in-progress" state to other classes or functions.
- 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