2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

Androidアプリ開発では長らく「MVVM + Clean Architecture」が定番の構成として扱われてきました。
Jetpack Composeが主流となった今も、この構成は大きくは変わらず、多くのプロジェクトに採用され続けています。

でも──本当にそれでいいのでしょうか?

「なんとなく良さそう」「みんなが使っているから」という理由だけでは、技術選択としては不十分です。
そんな問いから、この構成が 「なぜ今も選ばれ続けているのか」 を、実際の開発経験と個人的な視点も交えながら改めて掘り下げてみたいと思います。

構成の概要を整理してみる

まずは前提となる「MVVM + Clean Architecture」の構造を軽く整理します。

🏗️ 基本的な層構造

┌────────────────────────────────────────┐
│              UI Layer                  │
│  ┌─────────────┐  ┌─────────────────┐  │
│  │   Compose   │  │   ViewModel     │  │
│  │  (UI State) │  │ (State Holder)  │  │
│  └─────────────┘  └─────────────────┘  │
└────────────────────────────────────────┘
                   │
                   ▼
┌────────────────────────────────────────┐
│            Domain Layer                │
│  ┌─────────────┐  ┌─────────────────┐  │
│  │   UseCase   │  │     Entity      │  │
│  │ (Business   │  │  (Core Model)   │  │
│  │   Logic)    │  │                 │  │
│  └─────────────┘  └─────────────────┘  │
└────────────────────────────────────────┘
                  │
                  ▼
┌────────────────────────────────────────┐
│             Data Layer                 │
│  ┌─────────────┐  ┌─────────────────┐  │
│  │ Repository  │  │   DataSource    │  │
│  │ (Data       │  │  (API, DB,      │  │
│  │ Abstraction)│  │   Cache, etc)   │  │
│  └─────────────┘  └─────────────────┘  │
└────────────────────────────────────────┘

主な構成要素

  • UI層(Compose + ViewModel):画面描画と状態管理
  • ドメイン層(UseCase + Entity):ビジネスロジック
  • データ層(Repository + DataSource):I/O管理(API、DBなど)
  • DI(Hiltなど):依存注入で各層を疎結合に保つ

Composeの登場でView構築は大きく変わりましたが、構造自体は大きく崩れていないのがポイントです。

本当に必要?と疑問に思った瞬間

私自身、最近OSSの実装や社内のプロダクトなどでモダンな設計を意識する中で、「この構成、本当に毎回必要なのか?」とふと立ち止まりました。

🤔 具体的な疑問点

// こんな簡単な処理でも...
@HiltViewModel
class ForYouViewModel @Inject constructor(
    private val getFollowableTopicsUseCase: GetFollowableTopicsUseCase
) : ViewModel() {
    // ViewModelがあって...
}

class GetFollowableTopicsUseCase @Inject constructor(
    private val topicsRepository: TopicsRepository
) {
    // UseCaseがあって...
    suspend operator fun invoke(): List<FollowableTopic> {
        return topicsRepository.getFollowableTopics()
    }
}

interface TopicsRepository {
    suspend fun getFollowableTopics(): List<FollowableTopic>
}
  • ViewModelとUseCaseの切り分け、冗長じゃない?
  • 小規模なアプリでもレイヤーが多くて大げさに感じる
  • テストコードも含めて一式整えるのは手間がかかる

にもかかわらず、やはり多くのプロジェクトがこの構成を選んでいます。
ここから「なぜ選ばれるのか?」をいくつかの観点から探ってみました。

1. テストのしやすさはやはり強い

🧪 層別テスト戦略の威力

特にViewModelとUseCaseの責務が明確に分かれていることで、以下のような利点があります。

// ViewModelテスト:状態変化の確認
@Test
fun `トピック取得時の状態変化をテスト`() {
    // Given
    val mockTopics = listOf(
        FollowableTopic(Topic("1", "Android"), true)
    )
    coEvery { getFollowableTopicsUseCase() } returns mockTopics
    
    // When
    viewModel.loadTopics()
    
    // Then
    val uiState = viewModel.uiState.value
    assertThat(uiState.topics).isEqualTo(mockTopics)
}

// UseCaseテスト:ビジネスロジックの単体テスト
@Test
fun `フォロ済みトピックのみを返すことをテスト`() {
    // ビジネスロジックの純粋なテスト
    val result = filterFollowedTopicsUseCase(inputTopics)
    assertThat(result).isEqualTo(expectedFollowedTopics)
}

📊 テストピラミッドでの役割

        ┌─────────────────┐
        │   UI Tests      │ ← 少数・高コスト
        │   (E2E Tests)   │
        └─────────────────┘
      ┌───────────────────────┐
      │  Integration Tests    │ ← 中程度
      │  (Repository Tests)   │
      └───────────────────────┘
    ┌─────────────────────────────┐
    │      Unit Tests             │ ← 多数・低コスト
    │ (UseCase, ViewModel Tests)  │
    └─────────────────────────────┘

・テスト対象が"点ではなく線"として見えるようになる
→ テストを通じて構成の妥当性も自然に見えてくる

💡このテスト性の高さは、特に長期運用・チーム開発で大きな武器になります。

2. 構造の"拡張性"と"収縮性"がある

🌱 段階的成長に対応する設計

MVVM + Clean Architectureは構造がしっかりしている反面、最小構成でも動かせる柔軟さがあると考えます。
大規模プロジェクトでも、段階的に構築が可能です。

【フェーズ1:MVP段階】
feature:foryou → core:domain → core:data
     ↓
【フェーズ2:機能追加】
feature:foryou → core:domain → core:data
feature:bookmarks → core:ui   → core:database
                               → core:network
     ↓
【フェーズ3:本格運用】
feature:foryou → core:domain → core:data
feature:bookmarks → core:ui  → core:database
feature:topic     → core:model → core:network
feature:search                 → core:datastore

実際の成長パターンの例

// 段階1:シンプルな開始
class TopicsRepositoryImpl @Inject constructor(
    private val networkDataSource: NiaNetworkDataSource
) : TopicsRepository {
    override suspend fun getTopics(): List<Topic> {
        return networkDataSource.getTopics()
    }
}

// 段階2:キャッシュ追加
class TopicsRepositoryImpl @Inject constructor(
    private val networkDataSource: NiaNetworkDataSource,
    private val localDataSource: TopicDao
) : TopicsRepository {
    override suspend fun getTopics(): List<Topic> {
        return localDataSource.getTopics().ifEmpty {
            networkDataSource.getTopics().also { 
                localDataSource.insertTopics(it)
            }
        }
    }
}

// 段階3:複雑なビジネスロジック追加
class TopicsRepositoryImpl @Inject constructor(
    private val networkDataSource: NiaNetworkDataSource,
    private val localDataSource: TopicDao,
    private val syncManager: SyncManager
) : TopicsRepository {
    override suspend fun getTopics(): List<Topic> {
        val localTopics = localDataSource.getTopics()
        val shouldSync = syncManager.shouldSync()
        
        return if (shouldSync) {
            networkDataSource.getTopics().also {
                localDataSource.upsertTopics(it)
            }
        } else {
            localTopics
        }
    }
}

💡この構成は、あとから大きくしやすい構成であり、「将来に備えたコスト」が設計上の"保険"になっているとも言えます。

3. チームでの共通言語として機能する

👤コミュニケーションの効率化

開発体制が1人→複数人になると、「構成の説明ができるか」 が非常に重要になります。

【レビュー時の会話例】
👤 "この処理、どこに書くのが適切ですか?"
👥 "トピック取得のロジックならcore:domainのUseCaseですね"
👤 "APIレスポンスの変換は?"
👥 "それはcore:dataのRepositoryで対応しましょう"
👤 "このUI要素は?"
👥 "core:designsystemに共通コンポーネントがありますよ"

🎯 役割分担の明確化

┌─────────────────────────────────────────┐
│              責務の境界                  │
├─────────────────────────────────────────┤
│ feature:*    │ 画面の状態管理・イベント処理  │
│ core:domain  │ ビジネスルール・データ変換   │
│ core:data    │ 外部システムとの通信        │
│ core:model   │ ドメインモデルの定義        │
└─────────────────────────────────────────┘

💡共通言語として機能するということは、合意形成がしやすい構成でもあるということ。
新しいメンバーが加わっても、「この層では何をするか」が明確なので、オンボーディングが格段に楽になります。

4. Googleの公式サンプルが今も推奨している

📱 Now in Android の構成分析

例えば、公式の Now in Android プロジェクトでは、
MVVM + Clean Architectureベースの構成に UDF(Unidirectional Data Flow) などが加えられています。

// Now in Android の ViewModel パターン
@HiltViewModel
class ForYouViewModel @Inject constructor(
    private val userDataRepository: UserDataRepository,
    private val getFollowableTopicsUseCase: GetFollowableTopicsUseCase,
    private val getUserNewsResourcesUseCase: GetUserNewsResourcesUseCase,
    getSaveableNewsResourcesUseCase: GetSaveableNewsResourcesUseCase,
    private val analyticsHelper: AnalyticsHelper,
) : ViewModel() {
    
    val uiState: StateFlow<ForYouUiState> = combine(
        getFollowableTopicsUseCase(),
        getUserNewsResourcesUseCase(),
        userDataRepository.userData,
    ) { followableTopics, userNewsResources, userData ->
        ForYouUiState.Success(
            topics = followableTopics,
            feed = userNewsResources,
            // ... 複数のデータソースを組み合わせ
        )
    }.stateIn(/* ... */)
}

🔄 変化の柔軟性

Traditional MVVM + Clean
         ↓
   + Compose UI
         ↓
   + Unidirectional Data Flow
         ↓
   + Multimodule Architecture
         ↓
   + Kotlin Multiplatform

これは、「構造を維持しつつ進化させる」方向性を示していると捉えています。

💡Google自身がこれまでの設計を"壊さずに進化させている"という事実は我々の技術選定において、大きな比重を持っていると判断できます。

5. マルチモジュールと相性が良い

🏗️ MVVM + Clean Architectureにおいてのマルチモジュール構成

Now in Androidの実際の構成を参考に、MVVM + Clean Architectureに則ったマルチモジュール構成を提示します。

project-root/
├─ app/                                    # Main application module
│   ├─ src/main/kotlin/
│   │   ├─ MainActivity.kt                 # Entry point
│   │   ├─ NiaApplication.kt               # Application class
│   │   ├─ navigation/
│   │   │   ├─ NiaNavHost.kt              # Navigation graph
│   │   │   └─ TopLevelDestination.kt      # Nav destinations
│   │   └─ ui/
│   │       └─ NiaApp.kt                   # Main App composable
│   └─ build.gradle.kts
│
├─ core/                                   # Core shared modules
│   ├─ model/                              # Core domain models
│   │   ├─ src/main/kotlin/
│   │   │   ├─ data/
│   │   │   │   ├─ Topic.kt                # Core entities
│   │   │   │   ├─ NewsResource.kt
│   │   │   │   └─ UserData.kt
│   │   │   └─ DarkThemeConfig.kt
│   │   └─ build.gradle.kts
│   │
│   ├─ domain/                             # Core use cases
│   │   ├─ src/main/kotlin/
│   │   │   ├─ GetFollowableTopicsUseCase.kt
│   │   │   ├─ GetUserNewsResourcesUseCase.kt
│   │   │   └─ GetRecentSearchQueriesUseCase.kt
│   │   └─ build.gradle.kts
│   │
│   ├─ data/                               # Core data layer
│   │   ├─ src/main/kotlin/
│   │   │   ├─ repository/
│   │   │   │   ├─ TopicsRepository.kt     # Repository interfaces
│   │   │   │   ├─ NewsRepository.kt
│   │   │   │   └─ UserDataRepository.kt
│   │   │   ├─ util/
│   │   │   │   └─ NetworkMonitor.kt
│   │   │   └─ di/
│   │   │       └─ DataModule.kt
│   │   └─ build.gradle.kts
│   │
│   ├─ designsystem/                       # Design system & UI
│   │   ├─ src/main/kotlin/
│   │   │   ├─ component/
│   │   │   │   ├─ Button.kt               # Reusable components
│   │   │   │   ├─ TopAppBar.kt
│   │   │   │   └─ NavigationBar.kt
│   │   │   ├─ theme/
│   │   │   │   ├─ Theme.kt                # App theme
│   │   │   │   ├─ Color.kt
│   │   │   │   └─ Typography.kt
│   │   │   └─ icon/
│   │   │       └─ NiaIcons.kt
│   │   └─ build.gradle.kts
│   │
│   ├─ database/                           # Local database
│   │   ├─ src/main/kotlin/
│   │   │   ├─ dao/
│   │   │   │   ├─ TopicDao.kt
│   │   │   │   └─ NewsResourceDao.kt
│   │   │   ├─ model/
│   │   │   │   ├─ TopicEntity.kt
│   │   │   │   └─ NewsResourceEntity.kt
│   │   │   ├─ NiaDatabase.kt
│   │   │   └─ di/
│   │   │       └─ DatabaseModule.kt
│   │   └─ build.gradle.kts
│   │
│   └─ network/                            # Network layer
│       ├─ src/main/kotlin/
│       │   ├─ retrofit/
│       │   │   ├─ NiaNetworkApi.kt
│       │   │   └─ RetrofitNiaNetwork.kt
│       │   ├─ model/
│       │   │   ├─ NetworkTopic.kt         # Network DTOs
│       │   │   └─ NetworkNewsResource.kt
│       │   └─ di/
│       │       └─ NetworkModule.kt
│       └─ build.gradle.kts
│
├─ feature/                                # Feature modules
│   ├─ foryou/                             # For You feed feature
│   │   ├─ src/main/kotlin/
│   │   │   ├─ ForYouScreen.kt
│   │   │   ├─ ForYouViewModel.kt
│   │   │   └─ navigation/
│   │   │       └─ ForYouNavigation.kt
│   │   └─ build.gradle.kts
│   │
│   ├─ bookmarks/                          # Bookmarks feature
│   │   ├─ src/main/kotlin/
│   │   │   ├─ BookmarksScreen.kt
│   │   │   ├─ BookmarksViewModel.kt
│   │   │   └─ navigation/
│   │   │       └─ BookmarksNavigation.kt
│   │   └─ build.gradle.kts
│   │
│   └─ topic/                              # Topic details feature
│       ├─ src/main/kotlin/
│       │   ├─ TopicScreen.kt
│       │   ├─ TopicViewModel.kt
│       │   └─ navigation/
│       │       └─ TopicNavigation.kt
│       └─ build.gradle.kts
│
├─ sync/                                   # Background sync
│   └─ work/                               # WorkManager sync
│       ├─ src/main/kotlin/
│       │   ├─ workers/
│       │   │   └─ SyncWorker.kt
│       │   ├─ initializers/
│       │   │   └─ SyncInitializer.kt
│       │   └─ di/
│       │       └─ SyncModule.kt
│       └─ build.gradle.kts
│
└─ build-logic/                            # Gradle convention plugins
    ├─ convention/
    │   ├─ src/main/kotlin/
    │   │   ├─ AndroidApplicationConventionPlugin.kt
    │   │   ├─ AndroidLibraryConventionPlugin.kt
    │   │   └─ AndroidFeatureConventionPlugin.kt
    │   └─ build.gradle.kts
    └─ settings.gradle.kts

🔗 依存関係の明確化

┌─────────────────────────────────────────────────────────────┐
│                     依存関係の流れ                          │
├─────────────────────────────────────────────────────────────┤
│ app → feature:foryou, feature:bookmarks, feature:topic      │
│ app → core:ui, core:designsystem                            │
│                                                             │
│ feature:foryou → core:domain                                │
│ feature:bookmarks → core:ui, core:designsystem              │
│ feature:topic → core:model                                  │
│                                                             │
│ core:domain → core:data                                     │
│ core:domain → core:model                                    │
│                                                             │
│ core:data → core:database, core:network, core:datastore    │
│ core:data → core:model                                      │
│                                                             │
│ core:database → core:model                                  │
│ core:network → core:common                                  │
│ core:datastore → core:model                                 │
│                                                             │
│ sync:work → core:data, core:common                          │
└─────────────────────────────────────────────────────────────┘

📦 各モジュールの責務

┌─────────────────────────────────────────────────────────────┐
│                    モジュールの責務                          │
├─────────────────────────────────────────────────────────────┤
│ app                │ Navigation、DI統合、エントリーポイント    │
│ feature:*          │ 画面UI、ViewModel、機能別ナビゲーション   │
│ core:model         │ 共通ドメインモデル(Entity)             │
│ core:domain        │ UseCase、ビジネスロジック                │
│ core:data          │ Repository実装、データソース調整         │
│ core:designsystem  │ デザインシステム、Material 3コンポーネント │
│ core:database      │ Room database、DAO、Entity              │
│ core:network       │ API client、Network DTO                │
│ sync:*             │ バックグラウンド同期、WorkManager        │
│ build-logic        │ Gradle Convention Plugin               │
└─────────────────────────────────────────────────────────────┘

🎯 重要なポイント

  • プロジェクトルートレベルでのモジュール分割
  • feature間の独立性:feature同士は依存しない
  • core層の責務分離:model、domain、data、UIの明確な分離
  • Convention Pluginによる設定の統一化

各層が疎結合なので、機能ごとの切り出しや共通化もしやすい。
💡つまり、ComposeやKMPが広がる中で、この構造の持ち運びやすさは大きな強みです。

6. 実際の開発で感じる「安心感」

🦺 構造による心理的安全性

私自身の開発経験で感じるのは、この構成には 「迷子になりにくい」 という安心感があることです。

// 新しい機能を追加するとき
"どこに書けばいいか分からない"  少ない
"なぜこのコードがここにあるか分からない"  少ない
"どこから手を付けていいか分からない"  少ない

// 例
"新しいブックマーク機能を追加したい"
 feature:bookmarks モジュルを作成
 BookmarksScreen.kt, BookmarksViewModel.kt を配置
 core:domain  GetBookmarksUseCase を追加
 core:data  BookmarkRepository を実装

🎯 決断疲れの軽減

【毎回考える必要がないこと】
├─ ファイル配置: どの層に属するか明確
├─ 命名規則: 〇〇UseCase, 〇〇Repository, 〇〇Screen
├─ 依存関係: 上位層から下位層への単方向
└─ テスト方針: 層ごとのテスト戦略

7. 他の構成と比較してみる

🔍 MVI(Model-View-Intent)との比較

// MVI アプローチ
sealed class ForYouIntent {
    object LoadTopics : ForYouIntent()
    data class FollowTopic(val topicId: String, val followed: Boolean) : ForYouIntent()
}

data class ForYouViewState(
    val isLoading: Boolean = false,
    val topics: List<FollowableTopic> = emptyList(),
    val error: String? = null
)

// MVVM + Clean Architecture アプローチ
@HiltViewModel
class ForYouViewModel @Inject constructor(
    private val getFollowableTopicsUseCase: GetFollowableTopicsUseCase
) : ViewModel() {
    
    fun loadTopics() { /* ... */ }
    fun followTopic(topicId: String, followed: Boolean) { /* ... */ }
}

MVIは明確な状態管理ができる反面、学習コストが高く、小規模チームには重い
MVVM + Clean Architectureバランスが取れており、段階的に習得可能です。

📊 構成の比較マトリックス

構成 学習コスト 保守性 テスト容易性 チーム導入 マルチモジュール親和性
MVC
MVP
MVVM + Clean
MVI

✍️ 自分なりの結論

多くのプロジェクトで「MVVM + Clean Architecture」が選ばれる理由は、
完璧だからではなく、"破綻しにくく、持ち運びやすく、育てやすい"からだと考えました。

🎯 選ばれ続ける理由

  1. 技術的負債が蓄積しにくい構造
  2. チーム開発での意思決定コストが低い
  3. 段階的な成長に対応できる柔軟性
  4. エコシステムとの親和性
  5. 心理的安全性の提供
  6. マルチモジュール化への自然な移行

💡Jetpack ComposeやKMP、UDFなどの進化があっても、
この構成が地図としての役割を果たし続けていることが選ばれている最大の理由だと思います。

おわりに

構成そのものに正解はありませんが、「なぜこれが選ばれるのか」を考え続けることは、より良いアーキテクチャのヒントになります。

そして、重要なのは、「流行っているから」ではなく「なぜ機能するのか」を理解することだと改めて思いました。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?