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.
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:
- Lack of a single source of truth: The conversion logic is distributed across
foo-impl-module
andbar-impl-module
. If the conversion logic specification changes, one side might be updated while the other is overlooked, leading to bugs. - Dependency on the calling
*-impl-module
: To use the model conversiontoFooModel/toBarModel
, you need to depend on*-impl-module
instead of*-api-module
. Even to create an instance ofFooModel
, you need the implementation ofFooModelFactory
. - Unsafe downcasting: Since the
FooModel
interface itself does not havetoBarModel
, downcasting is necessary for conversion. However, there is no guarantee that there are no otherFooModel
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)
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? =
...
}
}
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.
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