1. 現場を覆う「Clean Architecture教」の病理
Android開発の現場で、ある種の「教条主義(ドグマ)」が蔓延しています。「プレゼンテーション層(UI / ViewModel)はデータ層(Repository)を直接知ってはならない」「すべての処理はドメイン層のUseCase(またはInteractor)を介して実行されなければならない」というルールです。
設計原則を重んじる姿勢自体は尊いものですが、その結果生み出されたコードベースの実態はどうなっているでしょうか? おそらく、読者の皆さんも一度は以下のようなコードを目にし、あるいは自ら書かされた経験があるはずです。
1行を右から左へ受け流すだけの「Pass-Through UseCase」
典型的なのが、何のビジネスロジックも持たず、単にRepositoryのメソッドをそのまま呼び出して返すだけのUseCaseです。
// 現場に溢れかえる空虚なPass-Through UseCase
class GetUserProfileUseCase @Inject constructor(
private val userRepository: UserRepository
) {
suspend operator fun invoke(userId: String): Result<UserProfile> {
// 独自の計算も、バリデーションも、複数Repositoryの集約も一切ない
return userRepository.getUserProfile(userId)
}
}
そして、呼び出し元のViewModelはこうなります。
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val getUserProfileUseCase: GetUserProfileUseCase
) : ViewModel() {
// 単にUseCaseを呼び出し、受け取ったデータをStateFlowに流すだけ
}
コードレビューで「このUseCase、Repositoryを直接呼ぶのと何が違うんですか?」と尋ねると、判で押したようにこう返ってきます。「将来ロジックが追加された時に備えて責務を分離しているんです」「クリーンアーキテクチャのレイヤードルールですから」。
しかし、これは真の抽象化ではありません。何ら価値を付加しない「関所の通行手形」にすぎず、クラス数、Dagger/Hiltの依存解決グラフ、ビルド時間、そして開発者の認知負荷を無駄に跳ね上げているだけです。
1フィールド追加するだけで5ファイル修正する「多重マッピング地獄」
Clean Architecture教がもたらすもう一つの悲劇が、過剰なレイヤードによる「DTO・エンティティの多重マッピング」です。
UserProfileResponse(Network API層のDTO)UserProfileEntity(Room DB層のエンティティ)UserProfile(Domain層のドメインモデル)UserProfileUiState(UI層のStateモデル)
たとえば、サーバー側のAPIレスポンスに isVerified: Boolean(本人確認済みフラグ)というフィールドが追加され、画面に青いチェックマークを1つ表示したいとします。このとき開発者は何を強いられるでしょうか?
「画面にアイコンを1つ出したいだけなのに、API DTO → DB Entity → Domain Model → UI State のすべてのデータクラスにフィールドを追加し、各レイヤー間の相互変換マッパー関数(toDomain, toEntity, toUiState)を書き換え、さらに1対1で対応するRepositoryインターフェースと実装クラスの定義を修正しなければならない」
変更の理由(Reason to Change)は「プロフィール画面にバッジを表示したい」というUIの要請ただ1つです。それにもかかわらず、水平に切り刻まれた4つのレイヤーすべてに修正が波及し、Gitのコミット差分は6ファイル・数十行に膨れ上がります。これのどこが「保守性が高い」と言えるのでしょうか?
2. なぜモバイルアプリでClean Architectureが歪むのか?
そもそも、Robert C. Martin(Uncle Bob)が提唱したClean Architectureや、ヘキサゴナル(ポート&アダプター)アーキテクチャ、オニオンアーキテクチャといった設計思想は、なぜ生まれたのでしょうか?
その背景を理解すれば、なぜ現代のモバイルクライアントでそれらが激しく軋みを上げるのかが明白になります。
Webバックエンドとモバイルクライアントの「関心の非対称性」
Clean Architectureが真価を発揮するのは、「複雑なビジネス不変条件(Business Invariants)」を死守しなければならないWebバックエンドや基幹系エンタープライズシステムです。
バックエンドの核心は「口座残高がマイナスにならないように送金トランザクションを完了させる」「在庫引き当てと決済の整合性を保つ」といった厳密な業務ルールです。データベースがPostgreSQLであろうとMongoDBであろうと、あるいはUIがWebフロントエンドであろうとバッチ処理であろうと、決して揺らいではならない「純粋なドメイン」が中心にドカンと鎮座しています。
一方で、モバイルアプリの関心は何でしょうか?
- UIとプレゼンテーションの制御: Jetpack Composeでいかに滑らかに描画するか、画面遷移のアニメーション、ローディング状態・空状態・エラー状態のUIハンドリング。
- プラットフォーム固有のライフサイクル管理: 画面回転、バックグラウンド遷移、プロセスキルからの復帰、パーミッション要求。
- サーバーデータのキャッシュと表示: サーバーから届いたJSONを整形してリスト表示し、ユーザーのタップ入力をサーバーへ送信する。
率直に言って、モバイルクライアントの関心の8割以上はUIとプレゼンテーションです。バックエンドのような「DBやUIから完全に独立した重厚なドメイン計算」など、一般的なモバイルアプリ(特にCRUD中心のアプリ)にはほとんど存在しないのが冷徹な現実です。
「Domain層」に置くべきロジックが存在しないという残酷な真実
存在しない「ドメインルール」を無理やりClean Architectureの枠組みに押し込もうとするから、以下のような歪んだ事態が頻発します。
- 中身のない「1行Pass-Through UseCase」が量産される。
- 本当に複雑なロジック(画面遷移の条件分岐、入力バリデーション、ユーザーの連打防止)は、UIに近い
ViewModelにすべて書かれてしまう。 - 結果として、「設計上はDomain層にビジネスロジックがあるはず」という建前と、「実態はViewModelにベタ書きされている」という本音が乖離し、新規参画メンバーがどこに何を書けばいいのか混乱して迷宮入りする。
3. Google公式ガイドが示す「Domain Layerはオプション」の真意
多くのAndroid開発者が盲点としている事実があります。Googleが公式に提示している『Guide to app architecture(アプリのアーキテクチャ ガイド)』において、Domain Layer(ドメイン層)は必須ではなく、明示的に「オプション(Optional)」と位置付けられているという点です。
"The domain layer is optional because not all apps have these requirements. You should only use it when needed—for example, to handle complexity or favor reuse."
(ドメイン層はオプショナルである。すべてのアプリがこれらの要件を持つわけではないからだ。複雑さへの対処や再利用の促進など、必要な場合にのみ使用すべきである)
—— Google Android Developers Architecture Guide
Googleの設計指針は極めて合理的です。「すべての画面・すべての処理に一律でUseCaseを挟む」のではなく、**「真に複雑性が生じた箇所にだけ局所的にUseCaseを導入せよ」**と明言しているのです。
UseCaseを作るべき「明確な3つの基準」
では、具体的にどのような状況であればUseCaseを導入すべきなのでしょうか? 現場で適用すべき判断基準は以下の3つに集約されます。
// 【OK】基準1: 複数のRepositoryからデータを集約・合成する場合
class GetUserDashboardUseCase @Inject constructor(
private val userRepository: UserRepository,
private val billingRepository: BillingRepository,
private val notificationRepository: NotificationRepository
) {
operator fun invoke(): Flow<UserDashboard> {
return combine(
userRepository.currentUserFlow,
billingRepository.subscriptionStatusFlow,
notificationRepository.unreadCountFlow
) { user, subscription, unreadCount ->
UserDashboard(
user = user,
isPremium = subscription.isActive,
unreadNotifications = unreadCount
)
}
}
}
- 複数Repositoryのオーケストレーション: 単一のViewModelが3つも4つもRepositoryを抱え込んで自前で合成すると、ViewModelが肥大化します。このようなデータの集約処理はUseCaseとして独立させる価値があります。
- 複数の画面(ViewModel)間で完全に重複するロジック: アプリ内の複数の画面で利用される共通の権限チェック、複雑な課金ステータス判定、特殊な暗号化/複合化処理など。
- UIライフサイクルから切り離して単体テストを高速に回したい純粋アルゴリズム: 複雑な日時計算やポイント算出ルールなど、ComposeやAndroid Frameworkの依存を一切排除してJUnitで秒速テストしたいロジック。
ViewModel → Repository のダイレクトアクセスは悪ではない
上記の3条件に当てはまらない通常のデータ取得(単一Repositoryの呼び出し)であれば、ViewModelから直接Repositoryを呼び出す設計が最もシンプルで堅牢です。
// 実践解: 余計なUseCaseを排し、RepositoryからStateFlowへ直結する
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
val uiState: StateFlow<ProfileUiState> = userRepository.getUserProfileFlow(userId)
.map { profile -> ProfileUiState.Success(profile) }
.catch { throwable -> emit(ProfileUiState.Error(throwable)) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ProfileUiState.Loading
)
}
何一つ問題ありません。データの流れ(Unidirectional Data Flow)は明快で、無駄な中継クラスが1つもなく、PRの行数は最小限で済みます。
4. 現場で本当に勝てる「Feature-Driven(垂直スライス)」アプローチ
Clean Architectureを盲信するチームが陥るもう一つの落とし穴が、「レイヤーごとの水平分割(Layer-by-Type)」パッケージ構造です。
# 変更の局所性を破壊する「水平レイヤード」構成
app/
presentation/
profile/ProfileScreen.kt, ProfileViewModel.kt
timeline/TimelineScreen.kt, TimelineViewModel.kt
domain/
usecase/GetUserProfileUseCase.kt, UpdateProfileUseCase.kt
model/UserProfile.kt
data/
repository/UserRepositoryImpl.kt
model/UserProfileDto.kt, UserProfileEntity.kt
この構成では、プロフィール機能に修正を加えるたびに、IDEのツリー構造の最上部から最下部まで何スクロールも移動し、何十個ものフォルダを展開しなければなりません。「関連するコードが遠く離れた場所に散らばっている」状態であり、これはソフトウェア設計において**最も悪名高い「散弾銃手術(Shotgun Surgery)」**の温床です。
垂直スライス(Feature-by-Feature)への転換
現場で変更容易性と凝集度を最大化するには、水平なレイヤーではなく**「機能(Feature)」ごとに垂直に切り分けるスライシング**を採用します。
# 変更が1箇所に凝集する「垂直スライス(Feature-Driven)」構成
feature/profile/
ProfileScreen.kt # UI (Compose)
ProfileViewModel.kt # State & Presentation Logic
ProfileRepository.kt # Data Access (必要なら直接API/DBを呼ぶ)
ProfileModels.kt # このFeature内で使われるデータモデル
プロフィール機能に関する要件変更があれば、feature/profile/ ディレクトリの中だけを見ればすべてが完結します。他の機能に影響を与える心配もなく、PRのレビューもこのディレクトリ配下の差分を確認するだけで済みます。
「1対1のInterface + Impl」撲滅運動
Clean Architecture至上主義者が「インターフェース分離」と称して必ず作るのが、実装が1つしかないRepositoryのインターフェースです。
interface UserRepositoryclass UserRepositoryImpl : UserRepository
「テストでモックに差し替えるために必要だ」と主張されますが、現代のKotlinテスト環境においては完全に時代遅れです。
- MockKを使えば具象クラスも直接モック可能: 具象クラスのままであろうと、
mockk<UserRepository>()で何の問題もなくテストダブルを注入できます。 - Fake(インメモリ実装)が必要になった瞬間に抽出すれば十分: 真にインメモリのFakeが必要になったその瞬間に、IDEのリファクタリング機能(Cmd+Option+T / Extract Interface)を使えば、1秒でインターフェースを切り出せます。
使われるかどうかもわからない将来のために、最初からすべてのクラスにInterfaceとImplをペアで作るのは、YAGNI(You Aren't Gonna Need It)の露骨な違反です。コード量を2倍にし、IDEの「定義へジャンプ(Cmd+Click)」で毎回どちらを開くか選択させられるストレスを生んでいるだけです。
5. まとめ: 「クリーン」とはレイヤーの多さではなく「認知負荷の低さ」である
アーキテクチャの真の目的とは何でしょうか? 書籍の図の通りに同心円を描くことでも、GitHubスターの多い有名テンプレートを完コピすることでもありません。
「アーキテクチャとは、システムの開発・変更・運用にかかる『人間の認知負荷と変更コスト』を最小化するための手段にすぎない」
1行を右から左へ横流しするだけのPass-Through UseCaseを量産し、DTOの相互変換コードを何百行も書き、インターフェースと実装クラスを形式的に二重管理することは、「クリーン」ではなく単なる「儀式(Cargo Cult)」です。
明日からの開発で、ぜひチームと以下の原則を共有してみてください。
- 基本は ViewModel → Repository の2層で組む(これで大半の要件は極めて軽快に動作する)。
- UseCaseは「3つの基準(複数Repo集約・横断的再利用・純粋アルゴリズム)」を満たす時だけ作る。
- 水平レイヤードではなく、Featureごとの垂直スライスで凝集度を高める。
- 実装が1つのRepositoryに無駄なInterfaceを作らない(YAGNIの徹底)。
無駄な抽象化とボイラープレートを脱ぎ捨てたとき、コードベースは驚くほど軽やかになり、私たちは本来注力すべき「ユーザーに素晴らしい体験を届けるプロダクト開発」に真に集中できるようになるはずです。