最近よく聞かれる、あの問題について
最近考えてたんだけど、Android開発でよく聞かれるのが「ViewModelがどんどん太っていく問題」なんだよね。最初はよかったんだけど、機能を追加していくうちに、ビジネスロジック、データ変換、UIの状態管理… もう、なんでもかんでもViewModelに詰め込んじゃって、気づいたら数千行のモンスターになってる、みたいな。正直、あるあるじゃない?
こうなるともう大変。コードは読みにくいし、ちょっとした修正が思わぬところに影響したり、ユニットテスト書くのも一苦労。開発スピードもガタ落ち。これを解決してくれるのが、いわゆる「クリーンアーキテクチャ」っていう考え方で、その心臓部とも言えるのが、今日話す「ドメイン層(Domain Layer)」なんだ。
TL;DR
要するに、ドメイン層っていうのは、アプリの「脳みそ」にあたるビジネスロジックを、UI(見た目)とかデータ(DBやAPI)とかから完全に切り離して、整理整頓するための専門エリア、って感じかな。ここをしっかり作っておけば、アプリが大きくなっても綺麗で、メンテしやすくて、テストしやすい構造を保てるわけ。
で、ドメイン層って具体的に何するところ?
じゃあ、ドメイン層って何よ?って話なんだけど、これはアプリの核となるビジネスルールを担当する、独立した層のこと。UIを扱うプレゼンテーション層と、データの保存とか取得をやるデータ層の間に位置するんだけど、こいつはどっちにも依存しない。これがすごく大事。
料理人で例えるとわかりやすいかも。ドメイン層は「最高のレシピを知ってるシェフ」。シェフは「この材料(データ)をこう調理(ロジック)すれば、最高の料理(結果)ができる」ってことだけを知ってる。材料がどこ(DBなのかネットワークなのか)から来たかとか、完成した料理がどんなお皿(UI)に盛られるかなんて、シェフの知ったこっちゃない。そういう役割分担だね。
このドメイン層には、だいたい以下の3つの要素が含まれてる。
- ユースケース (Use Cases): 「ユーザーをログインさせる」「商品をカートに追加する」みたいな、アプリの具体的な一連の処理(ビジネスロジック)をカプセル化したクラス。まあ、主役だね。
- ドメインモデル (Domain Models): アプリが扱う「商品」とか「ユーザー」みたいな、ビジネス上のエンティティ(実体)を表すデータクラス。すごくシンプルで、ビジネスに必要な情報だけを持つべき。
- リポジトリのインターフェース (Repository Interfaces): 「データをこうやって取ってきて」っていう「契約」だけを定義したもの。実際のデータ取得方法はデータ層が実装するから、ドメイン層は「どうやって」を知らなくていい。このインターフェースのおかげで、データ層と疎結合になれるわけだ。
ユースケースの作り方、これが結構大事
ドメイン層の核はやっぱユースケース。こいつの設計が、アプリ全体の綺麗さを決めると言っても過言じゃない。基本は「単一責任の原則(SRP)」を守ること。つまり、一つのユースケースは、一つのことだけをやるべき。
もし一つのクラスにpublicな関数が何個も生えてたら、それはもう「何でも屋」になってるサイン。ロジックを分割して、もっと小さな、専門的なユースケースに分けることを考えた方がいい。
命名規則、迷ったらこれ
ユースケースの命名は、チームでルールを決めておくと一貫性が出ていいよね。うちはだいたいこんな感じ。
[動詞] + [名詞] + UseCase
例えば `GetCustomerBagUseCase` とか `LogoutUserUseCase` みたいに、何をするクラスなのか一発でわかるようにする。これ、地味に大事。
ユースケースが依存していいもの、ダメなもの
ここがポイントで、ユースケースはプラットフォームから独立してるべき。つまり、純粋なKotlin/Javaの世界にいるべきなんだ。
- OK: 他のユースケース - 共通のビジネスロジックを再利用するため。でも、つなぎすぎると複雑になるから程々に。
- OK: 純粋なKotlinのユーティリティクラス - バリデーションルールとか、フォーマッターとかね。
- OK: リポジトリのインターフェース - データ層とやりとりするための唯一の窓口。
- NG: Androidフレームワークのクラス - `Context`とか`LiveData`、`Application`みたいなのは絶対ダメ。これに依存した瞬間、ドメイン層の独立性が失われて、ただのAndroidの便利クラスになっちゃう。非同期処理も、昔は`RxJava`が多かったけど、今は`Flow`か`suspend`関数を使うのが主流だね。とにかくAndroidに依存しないのが鉄則。
この辺、Googleが公式ガイドで推奨してるのはもちろんだけど、日本の開発現場、特にQiitaとか見てると、アーキテクチャに関する議論ってすごく活発だよね。たまに「どこまで厳密にやるか」で意見が分かれたりするのも面白い。でも、基本の「ドメイン層はAndroidを知らない」っていう原則は、だいたいどこも共通してると思う。
実践的なユースケースの書き方
理屈はわかったけど、じゃあ実際にどう書くの?ってことで、よくあるパターンをいくつか見ていこう。
その1: `invoke` 演算子で関数みたいに呼ぶ
ユースケースは一つのことしかしないんだから、Kotlinの `invoke` 演算子を使うと、クラスのインスタンスをまるで関数みたいに呼び出せる。これがすごくスマート。
class GetCustomerBagUseCase @Inject constructor(
private val userRepository: UserRepository, // interface
private val bagRepository: BagRepository // interface
) {
suspend operator fun invoke(): Bag {
val userId = userRepository.getUserId()
return bagRepository.getCustomerBag(userId)
}
}
こうしておけば、ViewModel側での呼び出しが `getCustomerBagUseCase.invoke()` じゃなくて `getCustomerBagUseCase()` って書ける。すっきりして気持ちいい。
その2: スレッド管理はデータ層に任せる
ユースケースは「メインスレッドセーフ」であるべき。つまり、UIスレッドから呼んでもUIを止めちゃダメ。ネットワーク通信とかDBアクセスみたいな時間のかかる処理は、データ層の仕事。データ層が責任を持ってバックグラウンドスレッド(例えば `Dispatchers.IO`)に切り替える。
…あ、でも例外もある。例えば、大量のリストをソートしたり、複雑な計算をしたりする「CPU負荷が高い」処理。これはビジネスロジックの一部だから、ドメイン層でやってもいい。ただし、その場合もUIスレッドをブロックしないように、`Dispatchers.Default` を使ってバックグラウンドで処理するのがお約束。
class FormatProductListUseCase @Inject constructor(
private val productRepository: ProductRepository,
// ... 他のUseCaseとか ...
@CoroutineDefault private val defaultDispatcher: CoroutineDispatcher
) {
suspend operator fun invoke(): List<Product> = withContext(defaultDispatcher) {
// データ取得はデータ層に任せる
productRepository.getProducts()
.sortedByDescending { it.price } // これはCPU負荷の高い処理
.map { product ->
// ... 他の変換処理 ...
product.copy(...)
}
}
}
ポイントは `CoroutineDispatcher` をDI(依存性注入)で外から渡してること。こうすることで、テストの時に本物のスレッドじゃなくてテスト用のディスパッチャに差し替えられて、テストがめちゃくちゃ書きやすくなる。
その3: 複数のリポジトリからデータを組み合わせる
「商品一覧」と「カート内の商品数」みたいに、複数のデータソースからの情報を組み合わせて一つの画面に表示したいことってよくあるよね。これをViewModelでやっちゃうと、またViewModelが太る原因になる。
こういう時こそユースケースの出番。複数のリポジトリを束ねて、いい感じにデータを加工して返す専門のユースケースを作るんだ。
class GetProductsWithBagCountUseCase @Inject constructor(
private val productsRepository: ProductsRepository,
private val bagRepository: BagRepository,
private val userRepository: UserRepository
) {
operator fun invoke(): Flow<ProductsWithBagCount> {
val userId = userRepository.getUserId()
// Flowのcombineを使って、複数のFlowを賢く合体させる
return combine(
productsRepository.getUserProducts(userId),
bagRepository.getUserBagCount(userId)
) { products, bagCount ->
ProductsWithBagCount(products, bagCount)
}
}
}
`Flow.combine` を使うのがミソ。これなら、どっちかのデータソースが更新されたら、自動で最新の組み合わせを発行してくれる。リアクティブで最高。ViewModelは、このユースケースが提供する一つの `Flow` を監視するだけでよくなるから、すごくシンプルになる。
その4: 入力値のバリデーション
「メールアドレスの形式が正しいか」「パスワードは8文字以上か」みたいな入力値のバリデーションも、立派なビジネスロジック。だから、これもドメイン層の仕事。
バリデーションロジックを専用のユースケースにまとめておけば、同じルールをアプリのいろんな場所で使い回せるし、一貫性も保てる。
// バリデーション結果を表すsealed interface
sealed interface ValidationResult {
data object Valid : ValidationResult
data object InvalidEmail : ValidationResult
data object InvalidPassword : ValidationResult
}
class ValidateUserRegistrationUseCase @Inject constructor(
private val emailValidator: EmailValidator,
private val passwordValidator: PasswordValidator
) {
suspend operator fun invoke(
email: String,
password: String
): ValidationResult {
return when {
!emailValidator.isValid(email) -> ValidationResult.InvalidEmail
!passwordValidator.isValid(password) -> ValidationResult.InvalidPassword
else -> ValidationResult.Valid
}
}
}
結果を `sealed interface` で返すのが今風だね。成功か、どのエラーなのかが型で表現できるから、ViewModel側での処理が `when` 式で綺麗に書ける。
ユースケース、どうやって管理する?
さて、作ったユースケースをViewModelでどうやって使うか。HiltみたいなDIライブラリを使うのが一般的だよね。
ここでよく議論になるのが、ライフサイクルスコープ。ユースケースはViewModelと同じ生存期間でいいことが多いから、`@ViewModelScoped` を付けることが多いかな。こうすれば、ViewModelが生きている間は同じユースケースのインスタンスが使い回される。
それからインジェクションの方法。毎回必ず使うユースケースなら普通にコンストラクタインジェクションでいいんだけど、たまにしか使わない、例えば「ウィッシュリストに追加」みたいな機能だったら、`Lazy<>` でインジェクションするのがおすすめ。
import dagger.Lazy
@HiltViewModel
class BagViewModel @Inject constructor(
// Lazyでインジェクト。get()が呼ばれるまでインスタンス化されない
private val saveItemToWishListUseCase: Lazy<SaveItemToWishListUseCase>
) : ViewModel() {
fun saveItemToWishList(item: Item) {
viewModelScope.launch {
// ここで初めて .get() が呼ばれてインスタンスが作られる
saveItemToWishListUseCase.get().invoke(item)
}
}
}
こうすることで、使われないかもしれないユースケースのインスタンスを無駄に生成しなくて済むから、メモリ効率とか起動パフォーマンスの観点からちょっとだけ有利になる。塵も積もれば、だね。
でも、毎回ユースケース経由って面倒じゃない?
ここで出てくるのが、すごく現実的な疑問。「ただリポジトリからデータを取ってくるだけなのに、わざわざユースケースを一枚噛ませるのって、コードが増えるだけで面倒じゃない?」
これ、めちゃくちゃわかる。正直、僕もそう思うことがある。このへんはプロジェクトの方針とか、チームの考え方によるから、絶対の正解はないんだよね。ちょっと比較してみようか。
| アプローチ | メリット | デメリット |
|---|---|---|
| ViewModelから直接リポジトリを呼ぶ | まあ、正直早いよね。書くコードも少ないし、シンプルな取得処理ならこれで十分じゃんって思う。 | これが地獄の始まり。最初は良くても、後から「あ、ここでログ仕込まなきゃ」とか「このデータはこういう条件でフィルタしないと」ってロジックがViewModelに生えてきて、結局カオスになる。 |
| 常にUseCaseを介する | ルールが統一されてて、すごく綺麗。ビジネスロジックは必ずユースケースにあるってわかるから、コードの可読性や保守性が爆上がりする。テストもユースケース単位で書けるから楽。 | 正直、ただデータを右から左に流すだけの時とか、「このクラス、いる?」ってなる。ボイラープレート感は否めない。慣れるまでは面倒に感じるかも。 |
僕のいるプロジェクトでは、基本的には後者の「常にユースケースを介する」ルールでやってる。なんでかって言うと、アプリが大きくなって、機能もモジュール分割されてくると、この一貫性がすごく効いてくるから。短期的な楽さより、長期的なメンテナンス性を取ってる感じだね。でも、個人の小さなアプリとかだったら、ケースバイケースで判断するのも全然アリだと思う。
まとめ
というわけで、ドメイン層の話をしてきたけど、どうだったかな。最初はちょっと面倒に感じるかもしれないけど、この「関心事の分離」っていう考え方を徹底することで、後々の自分が絶対に楽になる。
ViewModelをスリムに保ち、ビジネスロジックを再利用可能でテストしやすい「ユースケース」という部品に切り出す。この一手間が、大規模で複雑なアプリでも破綻しない、強いアーキテクチャの土台になるんだ。ぜひ、次のプロジェクトからでも意識してみてほしいな。
みんなが今まで格闘した一番ややこしいビジネスロジックってどんなやつ?よかったらコメントで教えてよ。
