bitFlyer Advent Calendar 2022の2日目です。
こんにちは、私はフロントエンド開発部でAndroidエンジニアをしています。
タイトルは、依存性逆転の原則
に対する私のファーストインプレッションです。
依存性逆転の原則とはSOLID原則の一つです。( Dependency Inversion Principle
)
これを調べると大抵 具体に依存せずにインターフェース(抽象)に依存すべきという原則 という説明がなされています。
合っているとは思うのですが、これだけだと「抽象に依存すべきというのはわかったけど、結局何が逆転したの??」という疑問が残りますよね。
ここをわかりやすく解説するのが本記事の目的です。
目次
1.素直に実装してみる
2.interfaceを使って抽象化してみる
3.結局どこが逆転していたのか
4.おわりに
1. 素直に実装してみる
カウンターアプリを例に考えていきます。
プラスボタンがあって、押すたびにカウントアップされていくだけのアプリだと思ってください。(Flutterを書いてる人は確実に見覚えがあるアレです)
カウントアップしていくだけなのですごくシンプルとはいえ、考えることはあります。
- インクリメントした数字をどう保存するか
- 数字をインクリメントするロジックはどこに書くか
などなど。
まず、1. から。
そのカウントされた数字はどうしましょう?
リモートDBに保存しますか?
それともローカルDBで良いでしょうか。
決まらないので、とりあえずin-memoryでRepositoryに保持しておくことにします。
class InMemoryCounterRepository {
private val _counter = MutableStateFlow(0)
fun saveCount(count: Int) {
_counter.update {
count
}
}
}
カウントアップするUseCaseも定義しちゃいましょう
class IncrementCountUseCase(
private val repository: InMemoryCounterRepository,
) : AbstractUseCase<Int, Unit>() {
override fun execute(params: Int) {
repository.saveCount(params + 1)
}
}
RepositoryとUseCaseを定義しました。
これらをクラス図にすると
UseCase
はRepository
に依存しています。
とても素直です。
ところが、です。
「誰でもそのカウンターの値を見られるようにしたいから、数字をリモートDBにも送って欲しい!!」という要求が突如として湧いてきたとします。(なんだそりゃ!?、という気持ちは抑えてください)
困りましたね・・・
2. interfaceを使って抽象化してみる
こんなときスマートにRepository
を置き換える方法があります。
依存性逆転の原則を利用します。
interfaceを用います。
interface CounterRepository {
fun saveCount(count: Int)
}
上記のinterfaceを、先ほどのInMemoryCounterRepository
に継承させて
class InMemoryCounterRepositoryImpl : CounterRepository {
private val _counter = MutableStateFlow(0)
override fun saveCount(count: Int) {
_counter.update {
count
}
}
}
上記の実装クラスを作成します。
そしてIncrementCountUseCase
のコンストラクタのRepository
の型をCounterRepository
に修正すると、クラス図は下記のように修正されます。
すごい!!!
よく見るやつです!
Repositoryの置き換えをスマートにやれますね。自作でMockクラスを作って返す値をコントロールしたりもしやすいのでとても便利です。
下記のような実装クラスをInMemoryCounterRepository
の代わりにインジェクトすれば「カウントアップした数字をリモートDBに保持したい!」いう新たな要件を満たせそうです。
class APIRepositoryImpl @Inject constructor(private val client: APIClient) : CounterRepository {
override fun saveCount(count: Int) {
client.updateCount(count)
}
}
無事に依存性が逆転しました!!!!
めでたしめでたし
・・・・とは私は思えませんでした。
「UseCase
は引き続きRepository
に依存してるように見えるけど、結局どこが逆転してるの??」 というのが率直な感想でした。
3. 結局どこが逆転していたのか
もう少しマクロな視点で見たらわかってきます。
1. interfaceを使う前のクラス図
Repository
とUseCase
のレイヤーを分けてみましょう。(レイヤーを分ける、というのはここではpackageを分けるくらいのイメージで大丈夫です)
結論として、UseCase
はDomain層に、Repository
はData層に分けます。(UseCaseにはロジックがあり、RepositoryはDataを扱うためです)
冒頭であった、 2. インクリメントのロジックをどこに置くか という話ですが、UseCase
に書いておくととても良いと思います。
なぜなら、Repository
側にロジックを漏らすと、Repositoryを差し替えたときにそのロジックを共通で使えません。※1
上記の図の矢印を見ると、依存関係は Domain -> Data
となっていますね。
実際にソースコードのimportの向きもこうなっています。
(※1 若干思想的な話になるのですが、よく聞かれる「UseCaseはRepositoryのメソッドを呼び出すだけで空っぽだから不要」という思想は一理あると思います。しかし私個人としては前述の理由でUseCase
はあった方が良いと考えています。)
2. interfaceを使った後のクラス図
次に、interfaceを使った場合のクラス図のレイヤーを分けてみましょう。
境界線を跨ぐ矢印の方向を見てください!!
さっきとは逆を向いていますね。
Data -> Domain
に 依存の方向が逆転 しています!!
ここにきて初めて何が逆転しているのか理解しました。
境界を越える矢印の向きが逆転していたんだ、と。
実際に図の通りにdomainとdataでディレクトリを分けると、この矢印の向きはimportの向きとも一致します。
(※実装クラスをDomain層でインスタンス化するとDomainのパッケージがDataのパッケージに依存してしまいimportの向きが狂ってしまうので、DIコンテナを使うことでうまく避けられます)
Domain層 -> Data層
だった依存の向きをData層 -> Domain層
と逆を向かせて何が嬉しいのか、というと
- Domain層がData層に依存しないのでDomain層を安定させられる( 例えばData層のライブラリを変更したり、レスポンスのプロパティが変更したときに都度Domain層に変更を入れずに済む )
- Domainを切り出してマルチモジュール化すると、依存性を強制させられる( 循環依存したりレイヤーの崩壊を避けやすい )
などなど
まとめると、変更に強く秩序あるコードにできる、という感じです。
胸のつっかえがとれましたね(とれていない方はガンガンコメントください!)
4. おわりに
「一体何が逆転しているんだ???」と胸につっかえを抱えている人が少しでもスッキリすれば良いなという願いがあり当記事を執筆しました。
実際にレイヤーを分けてマルチモジュール化したサンプルコードを置いておきます。
ぜひ参考にしてください!
また、弊社bitFlyerではAndroidエンジニアを募集しています。ご興味のある方はぜひご応募ください!