こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 44 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)
貧血の誤診
foo-module
と bar-module
の 2 つのモジュールがあり、それぞれのモジュール内 にデータモデル FooModel
と BarModel
が定義されているとします。
// In `foo-module`
class FooModel(val fooValue: Int)
// In `bar-module`
class BarModel(val barValue: ULong)
この FooModel
と BarModel
を相互に変換するロジックが必要になったと仮定しましょう。
この実装担当者は、貧血ドメインモデル を避けたいと考え、変換するロジックを FooModel
と 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
}
しかしこのままでは、foo-module
と bar-module
で依存が循環してしまいます。そこで、依存の循環を解消するために、以下のようにインターフェースのモジュール *-api-module
と実装のモジュール *-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)
}
これにより、モジュールの循環依存は解消されました。モジュールの依存関係を図示すると次のようになります。
しかし、このコードとモジュール構成には、いくつか問題点があります。それは何でしょうか?
鉄中毒を避ける
このコードとモジュール構成の問題点としては、以下のようなものが挙げられます。
- 信頼できる唯一の情報源 (single source of truth) の欠如: データモデルの変換ロジックが
foo-impl-module
とbar-impl-module
に分散している。変換ロジックの仕様が変わったとき、一方の変換ロジックは更新しつつ、反対側のロジックの更新を見落とすというバグが生じかねない。 - 呼び出し元の
*-impl-module
への依存: モデルの変換toFooModel/toBarModel
を使いたい場合、*-api-module
ではなく*-impl-module
に依存する必要がある。単にFooModel
のインスタンスを作成するためだけでも、FooModelFactory
の実装が必要になる。 - 安全でないダウンキャストの原因:
FooModel
インターフェース自身はtoBarModel
を持たないため、変換する場合はダウンキャストが必要となる。ただし、他のモジュールで別のFooModel
実装がないことは保証できない。
これらの問題を解決するために、いくつかの選択肢が考えられます。