🐡

Android開発でReposity, Impl, UseCase分ける必要ある?

に公開

初めてAndroid開発を学んだ時とりあえずCleanアーキテクチャが良いって書かれていたので勉強した記憶があります。
AndroidでCleanアーキテクチャと言うとよく登場するのがRepository, Repository Implementation, Usecaseを分離してコードを作成することです。
(ここでは具体的なCleanアーキテクチャの説明は省略します。)
最近仕事をする中で、これらを明確に分ける理由を説明し、納得してもらう必要が出てきたため、昔の記憶を辿りつつ、改めて整理してみることにしました。

Reposity, Impl, UseCaseが必要な理由

一般的に言われるRepository Interface, Repository Implementation, Usecaseを分ける理由は以下の通りです。

  1. それぞれのレイヤーが依存しないようにするため
    • Presentation Layer (UI/ViewModel)
    • Domain Layer (UseCase, Repository)
    • Data Layer (Repository Implementation, DataSource)
  2. ビジネスロジックをカプセル化しSRP(Single Responsibility Principle)を遵守するため

って言いますが。。。正直理解しずらい言葉だと思います。
そのためCleanアーキテクチャ違反したからではなく実際不便なところを中心で見せたいと思います。

Repositoryのみ使った場合

Repositoryのみ使う場合、ビジネスロジックをRepositoryに作成することになると思います。

class UserRepository {
    fun getUserInfo(): UserInfo {
        // UserInfoを取得する処理
    }
}

このコードを利用したViewModelは以下になります。

class UserInfoViewModel (private val userRepository: UserRepository): ViewModel() {
    fun handleUserInfo() {
        viewModelScope.launch {
            // 取得したUserInfoで何かをする作業
            userRepository.getUserInfo()
        }
    }

    fun updateUserInfo(userIndo) {
        viewModelScope.launch {
            // UserInfoをアップデートするため前処理実施
            userRepository.updateUserInfo(userIndo)
        }
    }
}

上記のような場合以下の問題が発生する可能性があります。

他の場所で同じロジックを繰り返す可能性がある

例えば、以下のようなViewModelでまたhandleUserInfoを使う必要がある場合は同じロジックを2箇所に書く問題が発生します。

class MyPageViewModel(private val userRepository: UserRepository) {
    fun handleUserInfo() {
        viewModelScope.launch {
            userRepository.getUserInfo()
            // UserInfoViewModelのhandleUserInfoと同じコード
        }
    }
}

Unitテストが不便になる

一般的にUnitTestはViewModelにある動作をテストします。
そのためhandleUserInfoメソッドをテストしたい場合はuserRepository.getUserInfo()をMock化する必要があります。
そのため以下のように機能を1つずつMock化する処理がTestコードに入る形になります。

class MyPageViewModelTest() {
    val mockRepository = mockk<SaleInfoRepository>()
    coEvery { mockRepository.getUserInfo() } returns Result.success(mockUserInfo))
}

テストケースが増えるたびにこのTemplateコードの繰り返しも増えるためよくない形になります。

Repository、Repository Implementationを使った場合

RepositoryだけではなくRepository Implementationを一緒に使うと何が変わるかを確認します。

Repository Implementationを追加すると以下のようにコードを変更できると思います。

interface UserRepository {
    fun getUserInfo(): UserInfo
}

class UserRepositoryImpl(): UserRepository {
    override fun getUserInfo():UserInfo {
        // UserInfoを取得する処理
    }
}

見た目の違いを言うとそれぞれ以下の特徴を持つと思います。

  • UserRepository: UserRepositoryで提供する機能を定義
  • UserRepositoryImpl: その機能リストから各機能を具体的に実装

では、UserRepositoryImplに機能を実装することで上記の問題のうち、どれを解決できるか確認します。

Unitテストが楽になる

Repositoryのみ使う場合はViewModelで使うものを1つずつMock化する問題がありました。

UserRepositoryImplを使うとUnitTest用の機能実装も分離ができます。

interface UserRepository {
    fun getUserInfo(): UserInfo
}

class UserRepositoryImpl(): UserRepository {
    override fun getUserInfo():UserInfo {
        // UserInfoを取得する処理
    }
}

class FakeUserRepository : UserRepository {
    override fun getUserInfo() = mockUserInfo
}

class MyPageViewModelTest() {
    val mockRepository = FakeUserRepository()
    val viewModel = MyPageViewModel(repository)
    
    @Test
    fun testGetUserInfo() {
        val userInfo = viewModel.getUserInfo()
        assertEquals("テストユーザー", userInfo.name)
    }
}

上記の例は機能も1つでViewModelも簡単ですが、 多くの機能を持つアプリの場合、UnitTest実装の手間がかなり減ることを実感できます。

じかし、まだRepositoryの機能を再利用できない問題は残っています。

Repository, Impl, UseCase全部使う場合

RepositoryとRepository Implementationはプロジェクトによって大きな違いがありません。interfaceを利用した定義があり、それを全部実装したclassファイルがある形です。

しかし、UseCaseはプロジェクトによって様々な構成を持つことが可能です。ここでは1:1マッチングさせる方法を利用し紹介します。

UseCaseまで使うと以下のような構成になります。

interface UserRepository {
    fun getUserInfo(): UserInfo
    fun updateUserInfo(userInfo: UserInfo)
}

class UserRepositoryImpl(): UserRepository {
    override fun getUserInfo():UserInfo {
        // UserInfoを取得する処理
    }
    override fun updateUserInfo(userInfo: UserInfo) {
        // UserInfoをアップデートする
    }
}

class GetUserInfoUseCase(
    private val userRepository: UserRepository
) {
    operator fun invoke(): UserInfo {
        return userRepository.getUserInfo()
    }
}

class UpdateUserInfoUseCase(
    private val userRepository: UserRepository
) {
    operator fun invoke(userInfo: UserInfo) {
        // UserInfoをアップデートするため前処理実施
        userRepository.updateUserInfo(userInfo)
    }
}

class UserViewModel(
    private val getUserInfoUseCase: GetUserInfoUseCase,
    private val updateUserInfoUseCase: UpdateUserInfoUseCase
) : ViewModel() {
    
    fun loadUserInfo() {
        viewModelScope.launch {
            val result = getUserInfoUseCase()
            // 結果処理
        }
    }
    
    fun updateUserInfo(userInfo: UserInfo) {
        viewModelScope.launch {
            val result = updateUserInfoUseCase(userInfo)
            // 結果処理
        }
    }
}

class LogoutViewModel(
    private val getUserInfoUseCase: GetUserInfoUseCase,
    private val logoutUseCase: LogoutUseCase
): ViewModel() {
    fun logout() {
        viewModelScope.launch {
            logoutUseCase(getUserInfoUseCase())
        }
    }
} 

上記のコードは以下の特徴を持っています。

  • Repositoryにある機能1つ=1つのUseCaseファイル
    • Repositoryにある機能をUseCaseにマッチングします。
  • UseCaseを追加することでViewModelで必要な機能のみ利用可能
    • UseCaseがない場合、不要な機能まで使えるようになる。

このようにRepository, Impl, UseCaseを全部使うとこの2つの問題を解決できました。

他の場所で同じロジックを繰り返す可能性がある
Unitテストが不便になる

では、Repository, Impl, UseCase全部使うが正解?

正解はないと思いますが、開発規模や目的によって便利になる構成はあると思います。
その理由はRepository, Impl, UseCaseが追加されることで構成が深くなり全体構成が理解しにくくなるためです。

そのため私の場合以下の目的で利用ケースを分けています。

  • Repositoryのみで大丈夫だと思うプロジェクト
    • 機能が簡単でUnitTestが必要ない
    • プロジェクトの修正が頻繁に行われない
  • Repository Implementationの追加が必要だと思われるプロジェクト
    • 一部にUnitTestが必要
    • 個人や小規模のプロジェクトでプロジェクト構成の複雑さを避けたい場合
  • UseCaseまで追加が必要だと思われるプロジェクト
    • 定期メンテナンスが必要なプロジェクト
    • 各機能に対してUnitTestが必須
    • コード品質やアプリの安定性を保つ必要があるプロジェクト

まとめ

Repository、Repository Implementation、UseCaseの分離は必須ではなく、状況に応じて適切に使用することが望ましいと思います。

小規模なプロジェクトやシンプルな機能の場合、過度な構造化は逆にコードの複雑性を高める可能性があります。一方、大規模プロジェクトや長期メンテナンスが必要な場合は、この分離がテスト容易性と保守性の向上に大きく貢献します。

重要なのは、プロジェクトの規模、チームの状況、メンテナンス要件を考慮し、最適な構成を選択することです。

Discussion