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 14: The only responsibility of assigning responsibility

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.

The only responsibility of assigning responsibility

Let's say you want to implement a "launch button" that launches either fireworks, a rocket, or a product. Additionally, this launch button needs to dynamically change what it launches when pressed.

The following LaunchButtonBinder takes the launch logic Launcher as a constructor argument. It registers an event listener in the init method to ensure the appropriate Launcher is executed when the button is pressed.

class LaunchButtonBinder(
    button: ObservableButton,
    private val fireworkLauncher: Launcher,
    private val rocketLauncher: Launcher,
    private val productLauncher: Launcher    
) {
    var launchMode: LaunchMode = LaunchMode.OFF

    init {
        button.observePush {
            launchMode.launcherSelector(this).launch()
        }
    }

    enum class LaunchMode(val launcherSelector: LaunchButtonBinder.() -> Launcher) {
        OFF({ Launcher.NOP_LAUNCHER }),
        FIREWORK(LaunchButtonBinder::fireworkLauncher),
        ROCKET(LaunchButtonBinder::rocketLauncher),
        PRODUCT(LaunchButtonBinder::productLauncher)
    }
}

This binder class is simple enough, but it can be seen as having two responsibilities: "binding the logic when the button is pressed" and "deciding which Launcher is active". Therefore, we changed it so that one binder class binds to one Launcher, and implemented the functionality to select the Launcher in a separate class called LaunchBinderSelector.

In this code, when an instance of LaunchBinderSelector is created, an instance of LaunchButtonBinder is created for each Launcher. Since each LaunchButtonBinder registers an event listener, there are a total of three listeners registered to the button. Additionally, to control which of the three listeners is executed, a property called LaunchButtonBinder.isEnabled was added.

class LaunchButtonBinder(
    button: ObservableButton,
    private val launcher: Launcher
) {
    var isEnabled: Boolean = false

    init {
        button.observePush {
            if (isEnabled) {
                launcher.launch()  
            }
        }
    }
}

class LaunchBinderSelector(
    button: ObservableButton,
    fireworkLauncher: Launcher,
    rocketLauncher: Launcher,
    productLauncher: Launcher
) {
    private val binders: Map<LaunchMode, LaunchButtonBinder> = mapOf(
        LaunchMode.FIREWORK to LaunchButtonBinder(button, fireworkLauncher),
        LaunchMode.ROCKET to LaunchButtonBinder(button, rocketLauncher),
        LaunchMode.PRODUCT to LaunchButtonBinder(button, productLauncher)
    )

    fun setLaunchMode(newMode: LaunchMode) = LaunchMode.entries.forEach { mode ->
        binders[mode]?.isEnabled = newMode == mode
    }

    enum class LaunchMode { OFF, FIREWORK, ROCKET, PRODUCT }
}

This way, we separated the logic implementation for the button and the responsibility of the Launcher. However, new problems have arisen. What are they?

Passing the buck of responsibility

One problem is "the responsibility to ensure the constraints of the specification is dispersed".

In the previous code, there was a constraint that only one thing would be launched when the button was pressed. In the new code, this is achieved through the combination of forEach, isEnabled, and getBinder, but to confirm this is correct, you have to read all the code. This problem can be solved by adding a property to LaunchBinderSelector that indicates which Launcher is selected. However, this would result in duplicated state between the added property and LaunchButtonBinder.isEnabled.

Another problem is related to the concealment of details.

In the split code, the role of LaunchBinderSelector was supposed to be only to select the LaunchButtonBinder. However, to create an instance of LaunchButtonBinder, it must directly depend on the Launcher. To conceal the Launcher from LaunchBinderSelector, you might consider directly holding instances of LaunchButtonBinder.

class LaunchButtonBinder(
    button: ObservableButton,
    private val launchAction: () -> Unit,
) {
    var isEnabled: Boolean = false

    init {
        button.observePush {
            if (isEnabled) {
                launchAction()
            }
        }
    }
}

class LaunchBinderSelector(
    private val fireworkBinder: LaunchButtonBinder,
    private val rocketBinder: LaunchButtonBinder,
    private val productBinder: LaunchButtonBinder,
) {
    fun setLaunchMode(newMode: LaunchMode) = LaunchMode.entries.forEach { mode ->
        getBinder(mode)?.isEnabled = newMode == mode
    }

    private fun getBinder(mode: LaunchMode): LaunchButtonBinder? = when (mode) {
        LaunchMode.OFF -> null
        LaunchMode.FIREWORK -> fireworkBinder
        LaunchMode.ROCKET -> rocketBinder
        LaunchMode.PRODUCT -> productBinder
    }

    enum class LaunchMode { OFF, FIREWORK, ROCKET, PRODUCT }
}

However, this means that the caller must resolve all dependencies (Selector, Binder, Button, Launcher). As a result, the caller may become bloated (the so-called "god class"), or you may need to read many classes to grasp the overall picture (the so-called "ravioli code").

LaunchBinderSelector(
    LaunchButtonBinder(button, fireworkLauncher),
    LaunchButtonBinder(button, rocketLauncher),
    LaunchButtonBinder(button, productLauncher)
)

The responsibility of considering responsibility

The initial implementation of LaunchButtonBinder had the advantage of implementing the specification constraints in one place, so overall, it may not have been necessary to split the class further.

When splitting classes, it is essential to avoid focusing too much on the responsibility range and cohesion within individual classes, leading to the caller's responsibility becoming bloated, or worsening the dependencies and coupling between classes and modules.


Summary in a nutshell

Be aware that splitting responsibilities can complicate dependencies.

Keywords: single responsibility, dependency, implicit constraints

List of articles on code quality improvement techniques