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 69: My tips for code quality

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

This article is the 69th installment in our "Weekly Report" series. At LY Corporation, we have a Review Committee that helps us maintain high development productivity. We share insights gathered through this committee internally as Weekly Reports, and we also publish some of them on this blog. (For more information about Weekly Reports, please see the list of past articles.)

My tips for code quality

This report is a bit different from usual: I’ll share five things I keep in mind when improving code quality.

1. Mind the gap between writing and reading

When writing code, it’s important not to simply translate whatever comes to mind directly into implementation. Instead, you should organize your thoughts with an awareness of how someone else will understand the code. Even if you write down the exact thinking process you went through to arrive at an idea, it won’t necessarily result in readable code. One reason is that the mental process of writing code differs from that of reading it. When writing, we often think bottom-up, while when reading, we often try to understand top-down.

Let’s use a simple example: "assign values to foo and bar." Suppose the values depend on a condition isX. When implementing this, you might go through a thought process like:

  1. If isX is true, set foo = 42 and bar = "WOW!"
  2. If isX is false, set foo = 24 and bar = "MOM?"

If you write that directly as code, you might end up with something like:

if (isX) {
    foo = 42
    bar = "WOW!"
} else {
    foo = 24
    bar = "MOM?"
}

When reading this code, the conditional branch using if is what stands out first. However, seeing if alone only tells you that behavior differs based on isX. To understand what the code actually does, you first need to realize that it assigns values to foo and bar in both branches, then recognize that the assignment itself is common to both cases, and only then reach the conclusion: "this is code that assigns values to foo and bar." For such a simple snippet, that’s a surprisingly long path to comprehension.

To help readers understand what the code does more quickly, you want to highlight what they need to know first: "this code assigns values to foo and bar." One way to achieve this is to move the branching inside the assignments, like this:

foo = if (isX) 42 else 24
bar = if (isX) "WOW!" else "MOM?"

Now, by just skimming the left-hand side, it’s immediately clear: "this code assigns values to foo and bar, and the assigned values are determined by isX."

This is not limited to this example. To make code easier to understand, it helps to surface state changes and a function’s main purpose at a shallow level of nesting (or a shallow call stack), while hiding or abstracting auxiliary logic such as edge-case handling. In particular, if code that mutates external state or performs a non-local exit is buried deep in a nested block, it becomes harder to grasp what the code does.

After you finish writing code, it’s useful to ask yourself, "How would someone seeing this for the first time understand it?" and simulate the thought process of someone who doesn’t already know the code.

This difference between the writer’s and reader’s mental models applies not only to code, but also to documentation and review comments. When writing a review comment, you may first notice an issue, organize why it’s an issue, and then come up with a fix. If you write the comment in that same order, readers have to wade through the rationale without knowing "what do you want me to do?" For review comments, it’s often better to start with the requested action, and then add reasons and references afterward.

Whether it’s code, documentation, or review comments, simply keeping in mind that writing and reading follow different thought processes can help development go more smoothly.

2. Validate before it becomes a bug

When writing code, keep in mind whether the people using it will understand the "correct" way to use it. Even better, if possible, design it so incorrect usage is impossible from the start. If you can’t avoid the possibility of misuse for some reason, it helps to contain that risk within a single function/class/module.

One important point: "correct behavior" and "code that is hard to misuse" can be achieved at different levels, for example:

  • Statically verified by the compiler or linters
  • Fully covered by test cases
  • Detected via runtime assertions
  • Defined in documentation
  • Defined by implicit, unwritten rules

The earlier an item appears in this list, the earlier (and more reliably) you can validate it. So, if possible, it’s preferable to satisfy higher-level guarantees. Of course, combining approaches can also be effective. Documentation in particular is worth pairing with other methods, as long as you can afford the ongoing cost of keeping it up to date as code evolves.

As an example, consider a function sendFoo that takes two integers as arguments, where both must be at least 42. If you define this constraint only as an implicit rule (with no documentation), it’s easy for someone to misuse it, for example by writing sendFoo(0, 0).

fun sendFoo(bar: Int, baz: Int) { ... }

To improve this, you could document the constraint and validate it at runtime:

/**
 * Sends FOO integers as ...
 *
 * Parameters [bar] and [baz] must be greater than or equal to 42.
 * Otherwise, [IllegalArgumentException] will be thrown.
 */
fun sendFoo(bar: Int, baz: Int) {
    require(bar >= 42) { "bar must be >= 42, but $bar" }
    require(baz >= 42) { "baz must be >= 42, but $baz" }

    ...
}

However, this approach still has risks: the documentation may be overlooked, and even if incorrect code like sendFoo(0, 0) slips in, you might not notice until it runs. Since this is a constraint on arguments, it can also be difficult to guarantee via unit tests that "all callers always pass correct arguments."

One better option is to make the constraint explicit using types. For example, by defining a Foo class like the following, you can ensure that bar and baz are both at least 42. Because callers must handle null to use the instance returned by of, they are forced to acknowledge the constraint.

class Foo private constructor(val bar: Int, val baz: Int) {
    companion object {
        /**
         * Creates a [Foo] instance with the given [bar] and [baz].
         * This returns null if either [bar] or [baz] is less than 42.
         */
        fun of(bar: Int, baz: Int): Foo? =
            if (bar >= 42 && baz >= 42) Foo(bar, baz) else null
    }
}

fun sendFoo(foo: Foo) { ... }

That said, this approach requires introducing a new type for every constraint, which can be overkill. If you want to avoid adding too many types, another option is to return an error state and force callers to handle it. (In Android Kotlin, @CheckResult is an annotation that encourages callers to check a function’s return value.)

/**
 * Sends FOO integers as ..., then returns true if success.
 *
 * Parameters [bar] and [baz] must be greater than or equal to 42.
 * Otherwise, this function does nothing with returning false.
 */
@CheckResult
fun sendFoo(bar: Int, baz: Int): Boolean {
    if (bar < 42 || baz < 42) {
        return false
    }

    ...
    return true
}

In dynamically typed languages where you can’t rely on type hints, encoding constraints via types may be difficult. Even then, linters and static analysis tools may help prevent misuse. And when static verification isn’t possible, it’s important to use unit tests and other techniques to detect misuse as early as possible.

This way of thinking applies not only when using code, but also when changing code for extensions or refactoring. By anticipating that developers (including your future self) may make incorrect changes, you can design systems that are more robust.

3. A principle for questioning principles

There are many principles and best practices aimed at improving code quality. However, none of them apply universally to every situation. Behind each principle, there are often assumptions, conditions, and constraints. If you ignore those and treat a guideline as universally applicable, you may end up with code that is more complex and harder to understand.

When applying principles and best practices, you should think about why they exist, decide which ones fit your current context, and prioritize accordingly.

As an example, consider the principle "keep visibility as narrow as possible." Suppose you have a function queryFollowers that is only called by another function fetchUserModel. If you follow the principle strictly, you might define queryFollowers as a local function inside fetchUserModel (a function within a function), like this:

fun fetchUserModel(userId: UserId) : UserModel {
    fun queryFollowers(): Set<FollowerId> {
        ...
    }

    val userName = queryUserName(userId)
    ...
    val followers = queryFollowers()
    return UserModel(userId, userName, ..., followers)
}

However, if you want to understand fetchUserModel at a glance, having queryFollowers defined inside it can make the overall flow harder to see. This becomes especially noticeable if there are many other query* functions also defined inside fetchUserModel.

Limiting visibility is important, but there are times when separating responsibilities and keeping the code easy to scan should take priority. In this case, it may be better to define queryFollowers as a separate private function instead of a local function. With private, visibility is still restricted to a reasonable scope, while the high-level behavior of fetchUserModel becomes easier to see.

fun fetchUserModel(userId: UserId) : UserModel {
    val userName = queryUserName(userId)
    ...
    val followers = queryFollowers()
    return UserModel(userId, userName, ..., followers)
}

private fun queryFollowers(): Set<FollowerId> {
    ...
}

What to prioritize (and how much) depends on the purpose and context of the code. When you learn a principle or best practice that seems great, don’t stop at "great." Also think about situations where it doesn’t apply and potential counterexamples. That helps you avoid being driven by the concept rather than the problem.

4. Look for improvements in the (0, 1) range

In general, development resources are limited. Sometimes you want to refactor before implementing a feature, but there isn’t enough time, and you have to keep moving while carrying existing technical debt. In those situations, it’s important to make improvements within what’s realistically possible, even if you can’t achieve the ideal state. Even if you can’t do a fundamental refactor, you may still have room for small, incremental improvements such as:

  • Write comments or tests that describe the current behavior
  • Create documentation describing the ideal design, and reference it from TODOs or issue tickets
  • Use an improved design in new code at least, and keep it as a "reference implementation"

In particular, it’s extremely important to make ideas like "we’re using design A for now, but ideally we want design B" visible to other developers. Without a shared understanding of the direction, it’s hard to decide what the interim code should look like. And when you share the ideal target, other developers may take it into account when making changes.

5. First: discuss. Second: discuss…

Whether it’s design, code, or documentation, it’s better to discuss as you build, rather than waiting to discuss after it’s "finished". If discussion is postponed, you risk major rework due to fundamental mistakes, and in some cases it can be mentally exhausting for both sides. By accumulating small discussions, you can respond quickly when issues arise and move forward with more confidence.

For that, it helps to have a team culture where discussion is enjoyable. (Outside of code reviews) you might even deliberately discuss seemingly minor details, such as whether to use tabs or spaces for indentation, or argue from a position opposite to your true opinion. Getting used to these kinds of discussions can lead to benefits like:

  • Improving your ability to verbalize ideas and communicate them
  • Learning how to separate opinions from personality/emotion and discuss constructively
  • Gaining experience combining your ideas with others’ to reach better conclusions
  • Lowering the psychological barrier to discussion itself

Ideally, that same openness makes it easy to approach teammates not only for debates, but also for organizing thoughts or asking questions. However, if discussions are limited only within your own team, knowledge can become siloed and skill sets may narrow. It would be even better to proactively reach out to members of other teams as well.

Thank you

In fact, this will be my last time publishing a report. Going forward, other members of the Review Committee plan to publish reports on an irregular basis, roughly around once a month. We hope you’ll continue to enjoy our series on techniques for improving code quality.

Keywords: summary, tips

List of articles on techniques for improving code quality