リアーキテクチャという山にみんなで登るために - 10 年ものの Android アプリを壊さずに作り変える -

こんにちは!株式会社ドワンゴ教育事業本部で、学習アプリ「ZEN Study」の Android 版開発を担当している本藤です。

ZEN Study Android は 10 年以上にわたって開発が続いているアプリで、その歴史の中でさまざまな技術選定の積み重ねがあります。その歩みについては、以前公開した ZEN Study Android アプリ 10年の技術的変遷 で詳しく紹介しています。現在、私たちはこのアプリのアーキテクチャを段階的に刷新する「リアーキテクチャ」に取り組んでいます。

10 年もののアプリのリアーキテクチャは、一気に登りきれる小山ではなく、チーム全員で少しずつ登っていく大きな山のようなものです。 この記事では、私たちがどんな課題を抱え、その山をどう登ろうとしているのかを、移行手順とあわせて紹介します。 特に、各ステップで常にビルド可能・リリース可能な状態を保ちながらアーキテクチャを刷新していく進め方を、具体的なコード例とともに紹介します。 同じように歴史の長いアプリのリアーキテクチャに悩む方の、地図代わりになれば幸いです。

なお、記事中のコードは構成を説明するためのイメージであり、実際のプロダクトコードそのものではありません。

現在の構成

ZEN Study Android では、ViewModel と UseCase の連携が少し特殊な構成になっています。これは RxJava を採用していた時代の名残で、ざっくり次のような関係になっています。

  • ViewModel が、イベントのトリガーとなる Flow を保持する
  • UseCase が、その Flow を監視し、イベントが流れてきたら処理を行う

つまり、イベントの起点は ViewModel が握り、実際の処理は UseCase が「監視する側」として担う形です。

現状の依存関係
現状の依存関係

現状の仕組みイメージ
現状の動作の仕組みイメージ

ここでは「読み込みボタンを押したらデータを取得する」という流れを例に、ViewModel と UseCase それぞれの実装を具体的に見てみましょう。ViewModel 側では、UI で発生したイベントを通知するための Flow と、画面の状態(読み込み中かどうか)を表す Flow を保持しています。

class SampleViewModel : ViewModel() {

    // UseCase が監視するトリガー用の Flow(読み込みイベント)
    private val _fetchEventFlow = MutableSharedFlow<Unit>()
    val fetchEventFlow: Flow<Unit> = _fetchEventFlow.asSharedFlow()

    // 読み込み中状態。View はこれを監視して表示する
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
    fun setLoading(value: Boolean) { _isLoading.value = value }

    // 読み込みボタンのクリックで呼ばれ、イベントを emit するだけ
    fun clickFetch() = viewModelScope.launch {
        _fetchEventFlow.emit(Unit)
    }
}

一方の UseCase は、ViewModel が公開する Flow を監視し、イベントが流れてきたら処理を行います。読み込み中状態の制御も、UseCase が ViewModel を直接操作して行います。

class LoadUseCase(
    private val viewModel: SampleViewModel,
    private val repository: SampleRepository,
) {
    // ViewModel の Flow を監視し、イベントが流れてきたら処理を実行する
    fun build(coroutineScope: CoroutineScope) {
        viewModel.fetchEventFlow
            .onEach {
                viewModel.setLoading(true)  // 読み込み中状態に変更
                repository.fetchData1()     // データ取得処理1
                repository.fetchData2()     // データ取得処理2
                viewModel.setLoading(false) // 読み込み中状態を解除
            }
            .launchIn(coroutineScope)
    }
}

そして、これらの UseCase は Fragment 側で「Fragment のライフサイクル」に紐づけて起動されます。

fun onViewCreated() {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            // Fragment の lifecycleScope で UseCase 群の監視を開始する
            loadUseCase.build(this)
        }
    }
}

このように「ViewModel は Flow を提供するだけ」「処理の本体は UseCase が監視側で持つ」「UseCase は Fragment のライフサイクルで動く」という構成になっています。RxJava 時代の宣言的なストリーム指向の設計を、Coroutines / Flow に置き換えながら引き継いできた結果です。

感じている課題

この構成は宣言的に書けるという利点がある一方で、長く開発を続ける中でいくつかの課題が顕在化してきました。

  1. 新メンバーの学習コストが高い

    一般的な MVVM では「View → ViewModel → 状態更新」という素直なデータフローが期待されます。しかし本構成では処理の本体が UseCase 側にあり、しかも UseCase が ViewModel の Flow を監視するという逆向きの依存です。そのため「このボタンを押したら、どこで何が起きるのか」を追うには、「ViewModel の Flow 定義 → それを監視する UseCase → UseCase の起動箇所」と複数ファイルを行き来しなければなりません。一般的な MVVM の知識もそのまま通用せず、学習コストが高くなっています。

  2. UseCase が並列に動き、タイミング制御が難しい

    一つの画面で多数の UseCase が独立した Flow として並行動作します(画面によっては十数個に及びます)。同じ ViewModel の状態を複数の UseCase が参照・更新するため、「UseCase A の後に B を動かしたい」といった順序制御を Flow の演算子(debounce や進行中フラグなど)で工夫せざるを得ず、複雑さの温床になっています。

  3. UseCase が Fragment の lifecycleScope で動くため、意図しない Coroutine キャンセルが起きる

    UseCase は Fragment の lifecycleScope 上で起動されるため、Fragment が破棄されると UseCase もキャンセルされます。画面遷移などで「本来実行されてほしかった処理」が、Fragment 破棄のタイミングと競合して実行されないことがあります。実際にコードベースには、この問題を回避するために UseCase を経由せず ViewModel のメソッドを直接呼ぶ、といった対症療法的な記述も残っています。

目指すアーキテクチャ

これらの課題を解決するため、私たちは UseCase が制御の中心となっている構成をやめ、クリーンアーキテクチャをベースとした MVVM (Model-View-ViewModel) 構成へ変更することで、制御の中心を ViewModel へ移行することにしました。 これは Google が公式に公開している アプリ アーキテクチャ ガイド に倣った構成でもあります。

目指すアーキテクチャ
目指すアーキテクチャ

目指す構成のポイントは次の通りです。

  • 制御の主体を ViewModel に移す:View はユーザー操作を ViewModel に伝え、ViewModel が UseCase を呼び出して処理し、結果を状態として View に返します。「UseCase が ViewModel を監視する」という逆向きの構成をやめ、処理を起点から呼び出す側である ViewModel に主導権を移します。
  • UseCase は ViewModel から呼ばれるシンプルなインターフェースにする:UseCase は operator fun invoke() を持つ呼び出し専用のインターフェースとし、ViewModel から必要なときに呼び出します。UseCase が ViewModel を知る(監視する)ことはありません。ViewModel に依存しなくなることで、UseCase 単体でのテストも書きやすくなります。
interface FetchDataUseCase {
    suspend operator fun invoke(): Result<Something>
}
  • 依存方向を内向きに統一する(クリーンアーキテクチャ):ビジネスロジックを担う domain が中心にあり、データ取得の詳細に依存しないようにします。そのために、domain が必要とするリポジトリのインターフェースを domain 側に定義し、repository がそれを実装する形にします。
// domain — 「こういうデータが欲しい」とインターフェースだけを定義
interface SomethingRepository {
    suspend fun fetchSomething(): Result<Something>
}
// repository — domain が定義したインターフェースを実装し、Data モデルを Domain モデルへ変換
internal class SomethingRepositoryImpl @Inject constructor(
    private val dataSource: SomethingDataSource,
) : SomethingRepository {
    override suspend fun fetchSomething(): Result<Something> =
        runCatching {
            dataSource.fetch().toDomain() // ← Data モデル → Domain モデル
        }
}
  • ロジックを viewModelScope に乗せる:ビジネスロジックを ViewModel から呼び出すことで、Fragment ではなく ViewModel のライフサイクル上で動かし、意図しないキャンセルのリスクを解消します。

この構成は、ビジネスロジックを担う domain、データの集約を担う repository、外部データ源との通信を担う datasource、UI を担う feature に役割を分けたマルチモジュール構成で実現します。domain は他のどの層にも依存しない中心に置かれ、依存方向が常に内側を向きます。

この「インターフェースと実装を別モジュールに分ける」構成を成立させるため、今回新たに DI コンテナとして Dagger Hilt を導入しました。インターフェース(SomethingRepository)は domain、実装(SomethingRepositoryImpl)は repository と別モジュールに置き、どの実装を結びつけるかは Hilt のモジュール定義に任せます。

// repository — どの実装を割り当てるかを宣言する
@Module
@InstallIn(SingletonComponent::class)
internal interface RepositoryModule {
    @Binds
    fun bindSomethingRepository(
        impl: SomethingRepositoryImpl,
    ): SomethingRepository
}

リアーキテクチャ前の構成では、依存オブジェクトを自前で生成して受け渡していました。リアーキテクチャを機に Hilt を導入したことで、インターフェースへの依存と実装の注入を仕組みとして分離でき、各モジュールの疎結合を保ちながら、依存方向を内向きに統一できるようになりました。

リアーキテクチャ手順

とはいえ、10 年以上動き続けているアプリを一度に作り直すことは現実的ではありません。私たちは「既存の動くコードを壊さずに、少しずつ新アーキテクチャへ移していく」という方針を取りました。具体的には、既存のコードを一旦全て Legacy モジュールへ隔離し、そこから新アーキテクチャ(New Architecture)へ機能を切り出していく、というアプローチです。

以降の各ステップでは、冒頭で見た「読み込みボタンを押したらデータを取得する」処理を共通の例として、その処理の流れが少しずつ Legacy から新アーキテクチャへ移っていく様子(「読み込み処理の流れ」の図)と、各ステップで整えるモジュール構成(「構成イメージ」の図)を見ていきます。

Step 0. リアーキテクチャを開始するための下準備

まず、移行の土台を作ります。

  1. 既存の構成を全て Legacy モジュールに移動する
  2. 目指すアーキテクチャ(新アーキテクチャ)のモジュールを作成する
  3. リファクタリングを行いながら、Legacy から新アーキテクチャへ移動していき、Legacy が空になったらリアーキテクチャ完了とする

Step0_既存をLegacyへ移動
Step0 既存をLegacyへ移動

ポイントは、最初に「Legacy という名前の置き場所」を明確に作ることです。こうすることで、どのコードが「まだ移行されていない旧構成」で、どのコードが「移行済みの新構成」なのかが一目で分かるようになります。新アーキテクチャ側には domainrepositorydatasourcefeature といった空のモジュールを用意し、これから埋めていく器を先に作っておきます。

Legacy モジュールは移行中に限り新アーキテクチャのモジュールに依存することを許可します。これにより「Legacy のコードから、移行済みの新しい部品を使う」ことができ、段階的な置き換えが可能になります。

一方で、その逆方向、つまり新アーキテクチャから Legacy への参照は禁止としています。

実際に移行を進めていると、「Legacy に既に動くロジックがあるのだから、新アーキテクチャからそれを呼び出してしまえば早いのでは」という誘惑にかられる場面が必ず出てきます。既存の資産を活かせるなら、わざわざ作り直さなくてもよいように思えるからです。しかし、これは絶対にやってはいけません。

理由は、循環参照を避けるためでもありますが、一番は Legacy の解体を妨げないためです。もし新アーキテクチャが Legacy を参照してしまうと、その Legacy のコードは新アーキテクチャのロジックの一部として組み込まれ、「Legacy でありながら、消すに消せない土台」になってしまいます。こうなると、せっかく新アーキテクチャ側へ移したはずのコードが再び Legacy に依存してしまい、リアーキテクチャそのものが前に進まなくなります。依存は常に Legacy →新アーキテクチャの一方向に限る、というルールが、後戻りしないための歯止めになっています。

Step 1. データモデルを Domain モデルと Data モデルに分離

ここからは、冒頭で見た「読み込みボタンを押したらデータを取得する」処理を例に、各ステップでこの流れがどう新アーキテクチャへ移っていくかを追いかけていきます。Step 0 を終えた時点では、下図のように Presentation・Domain・Repository のすべてがまだ Legacy にあり、処理の流れ自体は現状のままです。

Step1_仕組みイメージ
読み込み処理の流れ(Step 1 時点:すべて Legacy)

最初の実質的なステップは、データモデルの分離です。Legacy では API レスポンスの形をそのまま画面まで引き回しているような箇所があり、「外部システムの都合(JSON の構造やフィールド名)」と「アプリ内で扱いたい概念」が密結合していました。

これを、アプリの中核となる Domain モデルと、外部とのやり取りに使う Data モデルに分離します。

Step1_モデルをDomainとDataに分離
Step1 モデルをDomainとDataに分離

// domain — アプリ内で扱いたい「概念」を表す Domain モデル
data class Something(
    val id: SomethingId,
    val name: String,
    val isEnabled: Boolean,
)
// repository — 外部(API)の都合を反映した Data モデル。toDomain() で Domain モデルへ変換
data class SomethingData(
    val id: Int,
    val name: String?,   // API では null が返り得る
    val status: String,  // "enabled" / "disabled" などの文字列
) {
    fun toDomain(): Something = Something(
        id = SomethingId(id),
        name = name.orEmpty(),            // null を空文字に変換
        isEnabled = status == "enabled",  // 文字列を Boolean へ変換
    )
}

このように toDomain() による変換境界を設けることで、API の仕様変更が domain やそれ以降に波及しなくなります。id のような項目を単なる Int ではなく専用の型(値オブジェクト)で表現することで、型安全性やドメインロジックの置き場所も得られます。

モデルの分離は、後続の repositorydomain 移行の前提になるため、最初に着手しています。

Step 2. repository ・ datasource を新アーキテクチャに移行

次に、データ取得の仕組みを新アーキテクチャへ移します。手順は次の通りです。

  1. 新アーキテクチャへ datasource を実装する
  2. repository を新アーキテクチャへ移行する
  3. Legacy の UseCase で使用するリポジトリを、Legacy のものから新アーキテクチャのものに差し替える

先ほどの読み込み処理で言うと、下図のように repository だけが新アーキテクチャ側へ移ります。UseCase の動き(読み込み中状態の制御やデータ取得の呼び出し)は Legacy のまま変わりません。

Step2_仕組みイメージ
読み込み処理の流れ(Step 2 時点:repository が新アーキテクチャへ)

新アーキテクチャでは、データの流れを「datasourcerepositorydomain」という層で整理します。datasource は Remote API・DataStore・Room・Firebase といった具体的なデータ源ごとに分け、それぞれの取得手続きを担います。repository は、domain が定義したインターフェース(Step 1 で分離した Domain モデルを返す)を実装します。そのうえで外部の都合を反映した Data モデルを持ち、datasource から受け取ったデータを Domain モデルへ変換する役割を担います。

Step2_データ層を新アーキテクチャに移行
Step2 データ層を新アーキテクチャに移行

ここで重要なのは、Step 0 で「Legacy は新アーキテクチャに依存してよい」というルールを設けたことが効いてくる点です。repository を新アーキテクチャ側へ移したら、Legacy の UseCase が参照するリポジトリを新アーキテクチャ版に差し替えるだけで、UI の動きを変えずにデータ層だけを先行して移行できます。データ層は UI に比べて副作用が少なく、独立して移行しやすいため、先に着手しています。

Step 3. Legacy UseCase の処理を新アーキテクチャの UseCase へ切り出し

データ層が整ったら、いよいよビジネスロジックの移行に入ります。ただし、ここでもいきなり ViewModel まで作り変えるのではなく、まず「処理のまとまり」を新アーキテクチャの UseCase として切り出すことから始めます。

  1. Legacy UseCase の処理を、区切りの良い塊に分けて新アーキテクチャの UseCase として実装する
  2. Legacy UseCase から新アーキテクチャの UseCase を呼び出すよう変更する

読み込み処理の例では、下図のように「データ取得処理」の部分を New UseCase として切り出します。一方で「読み込み中状態の制御(true → 処理 → false)」のような、ViewModel の Flow 監視と結びついた制御は、まだ Legacy UseCase に残したままにします。

Step3_仕組みイメージ
読み込み処理の流れ(Step 3 時点:データ取得が新アーキテクチャの UseCase へ)

新アーキテクチャの UseCase は、operator fun invoke() で呼び出すシンプルなインターフェースとして定義します。ViewModel の Flow を監視するような仕掛けは持たず、呼ばれたときに処理を実行して結果を返すだけです。

interface FetchDataUseCase {
    suspend operator fun invoke(): Result<Something>
}

切り出した UseCase は、domain が定義したインターフェースを通じて repository に依存します。

Step3_LegacyUseCaseをNewUseCaseへ切り出し
Step3 LegacyUseCase を NewUseCase へ切り出し

この段階では、まだ呼び出し元は Legacy の UseCase(ViewModel の Flow を監視する旧構成)のままです。先ほどの LoadUseCase を見ると、「ViewModel の Flow を監視する」「読み込み中状態を制御する」といった枠組みは Legacy 側に残したまま、データ取得の中身だけが新アーキテクチャの FetchDataUseCase の呼び出しに置き換わります。

class LoadUseCase(
    private val viewModel: SampleViewModel,
    private val fetchData: FetchDataUseCase, // ← Repository ではなく新アーキテクチャの UseCase に依存
) {
    fun build(coroutineScope: CoroutineScope) {
        viewModel.fetchEventFlow
            .onEach {
                viewModel.setLoading(true)
                fetchData() // ← データ取得処理は新アーキテクチャの UseCase へ委譲
                viewModel.setLoading(false)
            }
            .launchIn(coroutineScope)
    }
}

このように、処理の中身は新アーキテクチャの UseCase へ少しずつ移し、Legacy UseCase は「New UseCase を呼ぶだけの薄い殻」に近づけていきます。こうすることで、ロジックの正しさを保ったまま、責務を新アーキテクチャ側へ移送できます。

ここで注目したいのは、Step 3 まで完了すると、Legacy UseCase に残る責務が「UI イベントを起点に、状態を更新しながら UseCase を呼び出す」だけになる点です。これは、まさに ViewModel が担うべき責務と酷似しています。言い換えれば、Legacy UseCase はもはや「ViewModel になりかけている」状態です。だとすれば、この責務はそのまま ViewModel に移してしまえばよい——これが、次の Step 4 で Legacy UseCase を解体し、ViewModel へ統合する自然な動機になります。

Step 4. Legacy UseCase の処理を ViewModel へ移行

最後のステップで、「ViewModel の Flow を UseCase が監視する」という特殊な構成そのものを解体します。

  1. Legacy UseCase が担っていた「いつ・どの処理を呼ぶか」という制御を、そのまま ViewModel 側へ移す
  2. ViewModel は UI からの操作を受け取り、Step 3 で切り出した新アーキテクチャ UseCase を直接呼び出す
  3. 読み込み中状態などの状態管理も ViewModel が持つ

ここでのポイントは、新しい設計を一から考えるのではなく、Step 3 で「ViewModel になりかけていた」Legacy UseCase の中身を、ほぼそのまま ViewModel に移すだけという点です。読み込み処理の例では、下図のように Legacy の Domain(Flow を監視していた UseCase)がなくなります。「読み込みボタンクリック時の処理」と読み込み中状態の制御は ViewModel の中に移り、データ取得は Step 3 で切り出した New UseCase を呼ぶ形に整理されます。

Step4_仕組みイメージ
読み込み処理の流れ(Step 4 時点:Legacy の Domain が解体され、処理が ViewModel へ集約)

これにより、Fragment のライフサイクルに縛られていたビジネスロジックが ViewModel(viewModelScope)上で動くようになり、データの流れも「View → ViewModel → UseCase → ViewModel → View」という、起点から結果まで追いやすい素直な形に整理されます。当初の課題がどう解消されるかは、後ほどコードを見ながら改めて整理します。

Step4_LegacyUseCaseを解体
Step4 LegacyUseCase を解体

具体的には、Step 3 時点の LoadUseCase が持っていた処理を、そのまま ViewModel のメソッドへ移します。Legacy UseCase では「ViewModel の Flow を監視して、流れてきたイベントに反応する」という形でしたが、移行後は ViewModel 自身がイベントの起点になるため、Flow の監視は不要になり、ボタンクリックから呼ばれるメソッドとして素直に書けます。

@HiltViewModel
class SampleViewModel @Inject constructor(
    private val fetchData: FetchDataUseCase,
) : ViewModel() {

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    // 読み込みボタンのクリックで呼ばれる。LoadUseCase の中身をそのまま移しただけ
    fun clickFetch() = viewModelScope.launch {
        _isLoading.value = true  // 読み込み中状態に変更
        fetchData()              // データ取得処理(新アーキテクチャの UseCase へ委譲)
        _isLoading.value = false // 読み込み中状態を解除
    }
}

旧構成では UseCase が ViewModel の Flow を監視し、読み込み中状態の変更まで UseCase 側から行っていました。新構成では、ボタンクリックを起点に ViewModel が UseCase を呼び出し、読み込み中状態の制御も含めて ViewModel に処理が集約されます。Legacy UseCase でトリガーに使っていた fetchEventFlow のような Flow も不要になります。

この時点で行っているのは、あくまで「Legacy UseCase の処理を ViewModel へ移す」ことだけで、設計を大きく作り変えてはいません。それでも、当初の課題はおおむね解消されます。ビジネスロジックが viewModelScope 上で動くようになり(課題 3 の解決)、処理の流れが ViewModel の一つのメソッドに集約されて追いやすくなります(課題 1 の解決)。また、複数の処理が絡む画面でも、呼び出し順序や状態の更新を ViewModel のコード上で明示的に書けるため、Flow の演算子で暗黙的にタイミングを制御していた頃の難しさもなくなります(課題 2 の解決)。

ある画面のすべての処理が ViewModel + 新アーキテクチャ UseCase へ移り終わると、その画面に対応する Legacy UseCase / UI は不要になり、削除できます。これを画面・機能ごとに繰り返し、最終的に Legacy モジュールが空になればリアーキテクチャ完了です。

リアーキテクチャの進行を可視化

リアーキテクチャの具体的な手順は明確になりましたが、「いつまで続くのか」「あとどれくらい残っているのか」がわからないまま登り続けるのは精神的にも辛く、モチベーションの低下につながってしまいます。 そこで私たちのチームでは、Legacy モジュールと新アーキテクチャそれぞれの Kotlin ファイル数、および新アーキテクチャへの移行割合を PR ごとに集計し、リアーキテクチャがどの程度進んでいるのかを可視化しました。 これにより、ゴールが見えるという精神的な安心感の他に、リアーキテクチャが停滞していないか、不必要に Legacy ファイルを追加していないかなどが検知できるというメリットも生まれました。

実際のメトリクスの推移は次の通りです。

リアーキテクチャメトリクス
リアーキテクチャメトリクス

おわりに

ここまで、ZEN Study Android のリアーキテクチャについて、抱えていた課題・目指す構成・移行手順を紹介しました。

ポイントは、「既存の動作を壊さず、Legacy に隔離してからデータ層 → UI 層へ順に移す」という段階的なアプローチです。10 年以上動き続けてきたアプリだからこそ、一度に作り変えるリスクは取らず、各ステップで常にビルド可能・リリース可能な状態を保ちながら進めています。

このリアーキテクチャ後は、「目指すアーキテクチャ」セクションの図の presentation 部で表現している UiState / UiAction / UiEffect を導入して状態とイベントを整理する、いわゆる MVI 寄りの構成へ発展させていく想定です。こちらの話は、別の機会に記事として紹介できればと思います。

もちろん、機能によっては、先に MVI 構成へ移してから Legacy UseCase を解体するなど、この記事で紹介した手順どおりには進められないケースもあります。それでも、基準となる手順を明確にしておくことで、そうした特殊なケースについても議論しやすくなります。

この山はまだ登っている途中ですが、移行が済んだ画面から順に「処理が追いやすくなった」「テストが書きやすくなった」といった手応えを得られています。Legacy が少しずつ空になっていく様子は、着実に標高が上がっているようで励みになります。同じ山を登ろうとしている方の地図代わりになれば嬉しいです。