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 64: Keep primary constructors simple

The original article was published on March 27, 2025.

Hello, I'm Masakuni Ōishi, an engineer working on the Android version of the LINE 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.

Keep primary constructors simple

Consider the following MessageCard class.

class MessageCard(userId: String, addressBook: AddressBook, messageTemplate: String) {

    val name: String = addressBook.getContact(userId)?.name ?: ""

    val birthDay: LocalDate? = addressBook.getContact(userId)?.birthDay

    val message: String = messageTemplate.replace(NAME_PLACEHOLDER, name)

    companion object {
        private const val NAME_PLACEHOLDER: String = "%NAME%"
    }
}

Is there anything wrong with this code?

Match primary constructor parameters to class properties

The properties of the MessageCard class are name, birthDay, and message. Meanwhile, the parameters of this class's primary constructor are userId, addressBook, and messageTemplate, which are only used to derive the values of the properties. Such an implementation is not very desirable.

Generally, it is best to avoid performing operations like "parsing" or "conversion" within the primary constructor. The parameters of the primary constructor should match the class's properties as closely as possible.

Therefore, the implementation of this MessageCard class should be as follows.

data class MessageCard(
    val name: String,
    val birthDay: LocalDate?,
    val message: String
)

This allows you to declare MessageCard as a data class as a bonus.

However, as it stands, there is no functionality to create an instance of MessageCard from userId, addressBook, and messageTemplate as in the original code. Such processing should be written in a secondary constructor or factory function.

Secondary constructor for "conversion"

Simple conversion-like processing is best described as a secondary constructor. For example, if an instance of MessageCard is generated from a Contact object and messageTemplate, the code would look like this.

data class MessageCard(
    val name: String,
    val birthDay: LocalDate?,
    val message: String
) {
    constructor(contact: Contact, messageTemplate: String) : this(
        contact.name,
        contact.birthDay,
        messageTemplate.replace(NAME_PLACEHOLDER, contact.name)
    )

    companion object {
        private const val NAME_PLACEHOLDER: String = "%NAME%"
    }
}

However, secondary constructors are not suitable for describing complex processing like the initial code.

Factory functions for flexible object creation

To describe object creation in a more flexible form, use factory functions. In Kotlin, factory functions are often described within a companion object.

data class MessageCard(
    val name: String,
    val birthDay: LocalDate?,
    val message: String
) {
    companion object {
        fun buildMessageCardOrNull(
            userId: String,
            addressBook: AddressBook,
            messageTemplate: String
        ): MessageCard? {
            val contact = addressBook.getContact(userId) ?: return null
            val message = messageTemplate.replace(NAME_PLACEHOLDER, contact.name)
            return MessageCard(contact.name, contact.birthDay, message)
        }

        private const val NAME_PLACEHOLDER: String = "%NAME%"
    }
}

Factory functions result in slightly verbose code, but they have the following advantages:

  • You can describe the object creation method in the form of a function name.
  • You can return a value like null to indicate that object creation failed.
  • You can generate and return an instance of a subclass of the target class, rather than the instance of the class itself.

Constructor-like factory functions

As a special case, you can use a top-level function with the same name as a class or interface as a factory function. This is called a constructor-like factory function because it can be used with the same appearance as a constructor call.

interface Foo {
    val name: String
    fun doSomething()
}

// Constructor-like factory function
fun Foo(): Foo {
    return FooImpl()
}

internal class FooImpl : Foo {
    override val name: String
        get() = TODO()

    override fun doSomething() {
        TODO()
    }
}


val foo: Foo = Foo() // This constructs an instance of `FooImpl`.

Constructor-like factory functions are often used in libraries like kotlinx.coroutines to separate interfaces and their implementation classes (e.g., kotlinx.coroutines.Job). However, conversely, it can be confusing because it looks like a constructor but is not actually a constructor, so it is best not to use it in application code.

In a nutshell

Avoid performing parsing or conversion operations within the primary constructor. Instead, implement secondary constructors or factory functions.

Keywords: constructor, factory function, data class

List of articles on techniques for improving code quality