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 44: Misdiagnosis of anemia

The original article was published on October 3, 2024.

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.

Misdiagnosis of anemia

Suppose there are two modules, foo-module and bar-module, each defining a data model FooModel and BarModel respectively.

// In `foo-module`
class FooModel(val fooValue: Int)

// In `bar-module`
class BarModel(val barValue: ULong)

Let's assume that we need logic to convert between FooModel and BarModel.

The implementer wanted to avoid an anemic domain model and implemented the conversion logic within FooModel and BarModel.

// In `foo-module`
class FooModel(val fooValue: Int) {
    fun toBarModel(): BarModel? =
        if (fooValue > 0) BarModel(fooValue.toULong()) else null
}

// In `bar-module`
class BarModel(val barValue: ULong) {
    fun toFooModel(): FooModel? =
        if (barValue <= Int.MAX_VALUE.toULong()) FooModel(barValue.toInt()) else null
}

However, this creates a circular dependency between foo-module and bar-module. To resolve this, the modules were split into interface modules *-api-module and implementation modules *-impl-module.

// `foo-api-module`
interface FooModel {
    val fooValue: Int
}
interface FooModelFactory {
    fun create(fooValue: Int): FooModel
}

// `foo-impl-module` 
class FooModelImpl(override val fooValue: Int): FooModel {
    fun toBarModel(): BarModel? =
        if (fooValue > 0) BAR_MODEL_FACTORY.create(fooValue.toULong()) else null

    companion object {
        private val BAR_MODEL_FACTORY: BarModelFactory =
            ... // Obtain a factory instance by service locator.
    }
}

class FooModelFactoryImpl : FooModelFactory {
    override fun create(fooValue: Int): FooModel = FooModelImpl(fooValue)
}

// `bar-api-module`
interface BarModel {
    val barValue: ULong
}
interface BarModelFactory {
    fun create(barValue: ULong): BarModel
}

// `bar-impl-module`
class BarModelImpl(override val barValue: ULong): BarModel {
    fun toFooModel(): FooModel? =
        if (barValue <= Int.MAX_VALUE.toULong()) FOO_MODEL_FACTORY.create(barValue.toInt()) else null

    companion object {
        private val FOO_MODEL_FACTORY: FooModelFactory =
            ... // Obtain a factory instance by service locator.
    }
}
class BarModelFactoryImpl : BarModelFactory {
    override fun create(barValue: ULong): BarModel = BarModelImpl(barValue)
}

This resolved the circular dependency. The module dependencies are illustrated as follows.

Dependency diagram. foo-impl-module and bar-impl-module each depend on both foo-api-module and bar-api-module

However, there are several issues with this code and module structure. What are they?

Avoiding iron overload

The issues with this code and module structure include:

  1. Lack of a single source of truth: The conversion logic is distributed across foo-impl-module and bar-impl-module. If the conversion logic specification changes, one side might be updated while the other is overlooked, leading to bugs.
  2. Dependency on the calling *-impl-module: To use the model conversion toFooModel/toBarModel, you need to depend on *-impl-module instead of *-api-module. Even to create an instance of FooModel, you need the implementation of FooModelFactory.
  3. Unsafe downcasting: Since the FooModel interface itself does not have toBarModel, downcasting is necessary for conversion. However, there is no guarantee that there are no other FooModel implementations in other modules.

To solve these issues, several options can be considered.

Option 1: Consolidate logic into one module

One way to resolve circular dependencies is to consolidate the conversion logic into either foo-module or bar-module. In the following implementation, foo-module depends on bar-module, and the conversion logic is consolidated in foo-module.

// `foo-module`
class FooModel(val fooValue: Int) {
    fun toBarModel(): BarModel? =
        if (fooValue > 0) BarModel(fooValue.toULong()) else null

    companion object {
        fun fromBarModel(barModel: BarModel): FooModel? =
            if (barModel.barValue <= Int.MAX_VALUE.toULong()) {
                FooModel(barModel.barValue.toInt())
            } else {
                null
            }
    }
}

// `bar-module`
class BarModel(val barValue: ULong)

Option 1 dependency diagram. foo-module depends on bar-module

This method is applicable when "all modules that depend on foo-module can also depend on bar-module". This applies when BarModel is a more primitive type compared to FooModel. However, if FooModel and BarModel are of similar complexity, this method is inappropriate.

Option 2: Create an intermediate model for conversion

Another method is to create an intermediate model IntermediateModel for conversion and implement the conversion logic within FooModel and BarModel. By defining IntermediateModel in a separate module intermediate-module, independent of both foo-module and bar-module, circular dependencies can be avoided.

// `intermediate-module`
class IntermediateModel(...)


// `foo-module`
class FooModel(val fooValue: Int) {
    fun toIntermediateModel(): IntermediateModel? = ...

    companion object {
        fun fromIntermediateModel(model: IntermediateModel): FooModel? =
            ...
    }
}

// `bar-module`
class BarModel(val barValue: ULong) {
    fun toIntermediateModel(): IntermediateModel? = ...

    companion object {
        fun fromIntermediateModel(model: IntermediateModel): BarModel? =
            ...
    }
}

Option 2 dependency diagram. foo-module and bar-module depend on intermediate-module

This method is effective when there are many data models that require mutual conversion. However, if there are only two data models, FooModel and BarModel, creating an intermediate model may be over-engineering.

Option 3: Separate the conversion logic

Fundamentally, the aforementioned issues arise from "forcing logic into data models". Including logic in data models is a means to hide details, not an end in itself. Generally, logic or algorithms depend on data models, but the reverse is rare. It is often better to maintain the direction of dependency between logic and data rather than combining them.

In this case, the dependency relationship between data models and conversion logic is as follows.

Option 3 dependency diagram. Mutual conversion logic depends on foo-module and bar-module

Expressing this dependency relationship in code results in the following.

// `foo-bar-converter-module`
object FooBarConverter {
    fun createFooModel(barModel: BarModel): FooModel? =
        if (barModel.barValue <= Int.MAX_VALUE.toULong()) FooModel(barModel.barValue.toInt()) else null
    
    fun createBarModel(fooModel: FooModel): BarModel? =
        if (fooModel.fooValue > 0) BarModel(fooModel.fooValue.toULong()) else null
}

// `foo-module`
class FooModel(val fooValue: Int)

// `bar-module`
class BarModel(val barValue: ULong)

The advantage of this structure is that if a module only needs either FooModel or BarModel, it does not need to depend on the other data model or conversion logic. Unlike Option 1 or 2, when depending on foo-module for FooModel, there is no need to depend on bar-module or foo-bar-converter-module.

Which prescription is best?

As explained earlier, the situations where Option 1 or Option 2 are appropriate are limited.

Option 1: Applicable when using one data model always requires the other. This can occur when one conceptually or structurally includes the other.

Option 2: Applicable when the source/target of data model conversion is used in a broader scope than the data model module. A typical example is when the source/target is defined in an interface description language like Protocol Buffers.

In other cases, it is often appropriate to separate data models and conversion logic as in Option 3. Furthermore, even in situations where Option 1 or 2 can be used, Option 3 may be preferable.

Integrating data and logic into objects is a fundamental concept of object-oriented programming. However, making it an end rather than a means can lead to abandoning the analysis of dependency relationships between data and logic. Whether data or logic, organizing the direction of dependencies first, and then considering what elements to include where, can lead to smooth design.

In a nutshell

Avoid making the integration of logic and data an end, and maintain the direction of dependencies.

Keywords: dependency direction, data model, anemic domain model

List of articles on techniques for improving code quality