はじめに
〜モジュール分割・並行処理・アーキテクチャの最適バランス〜
「どこまで細かく分けるべきか?」
それがすべての設計の出発点であり、終着点。
1. 粒度(Granularity)とは
「粒度」とは、設計単位の細かさ(粒の大きさ) を指します。
クラス・関数・API・モジュールなど、どのレベルでも「どこまで分けるか」を判断する基準です。
| 粒度タイプ | 概要 | 例 | メリット | デメリット |
|---|---|---|---|---|
| 粗い粒度(Coarse-grained) | 大きくまとめる | 「UserService」が登録もログインも担当 | 呼び出しが少なく単純 | 再利用性が低い/変更影響が大きい |
| 細かい粒度(Fine-grained) | 小さく分ける | 「UserRegistration」「UserAuth」「UserProfileUpdater」 | 再利用性・テスト容易性が高い | 管理コストが増える/複雑化 |
2. 粒度設計の目的
粒度を調整する理由は「柔軟性と安定性のバランス」にあります。
以下の観点で最適粒度を決めましょう。
| 観点 | 小粒度が有効な場合 | 大粒度が有効な場合 |
|---|---|---|
| 再利用性 | 共通処理を多くの文脈で使いたい | 固定ロジックをまとめたい |
| 保守性 | 機能単位で頻繁に修正 | 大きな単位で管理したい |
| パフォーマンス | 並列処理・キャッシュに分割したい | 通信や同期コストを減らしたい |
| 責務分離 | SRP(単一責任)を徹底したい | 責務境界が明確で安定している |
3. 粒度設計の基本原則
原則1:責務単位で分ける(SRP)
「1つのモジュール=1つの目的」
// ❌ 責務が混在
class UserService {
fun register() { ... }
fun login() { ... }
fun sendMail() { ... }
}
// ✅ 責務を分離
class UserRegistrationUseCase
class UserLoginUseCase
class MailSender
原則2:変更単位を一致させる
「同時に変更されるコードは、同じモジュールに置く」
- 仕様変更で同時に手を入れる部分は、同一粒度にまとめる
- 頻度が異なる変更箇所は分離しておく(疎結合化)
原則3:通信・同期コストを考慮する
「細かく分けすぎると、逆に遅くなる」
APIやマイクロサービスでは「細粒度=呼び出し回数増加」。
→ ネットワーク・DB・IOの境界では粒度を粗く保つことが多い。
4. Clean Architecture における粒度階層
Clean Architectureでは、内側ほど粗く・外側ほど細かくが原則。
| レイヤー | 粒度 | 役割 | 変更頻度 |
|---|---|---|---|
| Entity(Domain Model) | 粗い | ビジネスルール | 安定 |
| UseCase(Interactor) | 中粒度 | アプリケーションの操作単位 | 中程度 |
| Interface Adapter(Repository, ViewModel) | 細かい | I/O変換・UI制御 | 頻繁に変化 |
| Framework層(UI, DB, API) | 最も細かい | 実装詳細 | 高頻度で変化 |
理想構造:
粒度は内側に行くほど「安定性重視」、外側に行くほど「変化対応重視」。
5. 並行処理 × 粒度設計
並行処理(Coroutine, Flowなど)では、粒度の取り方が直接性能と安全性に影響します。
5.1 UseCase単位で並列化する(中粒度)
suspend fun loadDashboard(): Dashboard = coroutineScope {
val news = async { newsRepo.fetch() }
val profile = async { userRepo.fetchProfile() }
val ranking = async { rankingRepo.load() }
Dashboard(news.await(), profile.await(), ranking.await())
}
- 並列化の単位=UseCase(中粒度)
-
合流点(await) =UseCase内部
→ ViewModelやUI層にDeferredを漏らさない。
5.2 I/O単位で分けすぎると逆効果
-
Repositoryの中で小粒度asyncを多発するとスレッド争奪戦に。 - 非同期I/Oは1呼び出し=1Suspend関数が理想。
5.3 キャンセル境界を粒度に合わせる
「キャンセルできる単位」でスコープを分ける。
→ coroutineScopeとsupervisorScopeの選択も、粒度境界と一致させる。
6. API粒度設計(Backend / REST)
APIでも同様に「細粒度 vs 粗粒度」のバランスが重要。
| 粒度 | 例 | メリット | デメリット |
|---|---|---|---|
| 粗い粒度API |
/user/setup → 登録+初期化+メール送信 |
呼び出しが1回で済む | 拡張が難しい/変更影響が広い |
| 細かい粒度API |
/user/register /user/mail /user/profile
|
柔軟・再利用可能 | 呼び出し回数が増える |
💡 BFF(Backend for Frontend) を活用すると、UIに合わせて適度な粒度にまとめられる。
7. 粒度設計の実戦基準
| 項目 | 判断基準 | 対応方針 |
|---|---|---|
| 責務 | 単一責任か? | SRPで再分割 |
| 変更頻度 | 他と異なるか? | 頻度別に層を分ける |
| 依存関係 | 双方向になっていないか? | 一方向依存に修正 |
| 通信境界 | オーバーヘッド過多か? | 境界を粗く統合 |
| 並行性 | 独立して実行できるか? | スコープ単位で分割 |
| 再利用性 | 異Contextでも使えるか? | 小粒度に分解 |
8. 粒度設計とリファクタリング
- 最初から最適な粒度を決める必要はない。
- コードレビュー・実行時間・テスト範囲から「粒度のズレ」を発見する。
- 境界を整理するために:
- 関数を抽出(
Extract Method) - クラスを分離(
Extract Class) - 機能をまとめる(
Inline Function)
- 関数を抽出(
粒度設計とは「時間と変化の設計」である。
今の最適が、将来も最適とは限らない。
9. 粒度設計チェックリスト
| チェック項目 | YES/NO |
|---|---|
| 各モジュールは明確な目的を持っているか? | |
| 責務が1つに絞られているか?(SRP) | |
| 同時変更の範囲が最小か? | |
| 並列実行の粒度は適切か? | |
| API・DB呼び出しの境界を粗くできているか? | |
| 例外処理・キャンセルの単位が一致しているか? | |
| テスト単位(ユニット)と粒度が対応しているか? |
10.UseCase × Repository × Coroutine 実装例
10.1 全体像(Mermaid)
-
粒度方針:
- UseCase(中粒度) で並列化&合流点を管理
- Repository(細粒度) は「1 I/O = 1 suspend」
- UI/VM(粗粒度) は操作単位でスコープを張る
10.2 Domain / DTO
data class Article(val id: String, val title: String)
data class Profile(val userId: String, val name: String)
data class Recommendation(val items: List<String>)
data class HomeModel(
val articles: List<Article>,
val profile: Profile,
val recommendation: Recommendation
)
10.3 Repository(細粒度・1 I/O = 1 suspend)
interface NewsRepository {
suspend fun fetchTop(): List<Article>
}
interface UserRepository {
suspend fun fetchProfile(): Profile
}
interface RecommendRepository {
suspend fun recommendFor(profile: Profile): Recommendation
}
実装例では
withContext(Dispatchers.IO)を実装層に寄せます(呼ぶ側は気にしない)。
class NewsRepositoryImpl(
private val api: NewsApi,
private val io: CoroutineDispatcher = Dispatchers.IO
) : NewsRepository {
override suspend fun fetchTop(): List<Article> = withContext(io) {
api.getTop().map { Article(it.id, it.title) }
}
}
10.4 UseCase(中粒度・並列化&合流点・キャンセル境界)
class LoadHomeUseCase(
private val news: NewsRepository,
private val users: UserRepository,
private val reco: RecommendRepository,
private val default: CoroutineDispatcher = Dispatchers.Default
) {
/**
* 1. 同一操作の範囲で coroutineScope を張る
* 2. async で並行実行
* 3. 合流点(await)は UseCase 内に閉じる
* 4. 全体にタイムアウトを適用(必要なら)
*/
suspend fun execute(timeoutMs: Long = 2_500L): HomeModel =
withTimeout(timeoutMs) {
coroutineScope {
val articles = async { news.fetchTop() } // IO
val profile = async { users.fetchProfile() } // IO
// 依存順序がある場合:await後に使う or async(start=UNDISPATCHED)等で工夫
val recommendation = async(default) {
val p = profile.await()
reco.recommendFor(p) // CPU/サービス
}
HomeModel(
articles = articles.await(),
profile = profile.await(),
recommendation = recommendation.await()
)
}
}
}
補足
-
withTimeoutは UseCase 粒度で適用し、UIでは乱用しない -
async未合流は厳禁(ゾンビ化・リークの温床) - Dispatcherは注入し、テストで
StandardTestDispatcherに差し替え可能
10.5 ViewModel(粗粒度・ライフサイクル連動)
sealed interface HomeState {
object Loading : HomeState
data class Data(val model: HomeModel): HomeState
data class Error(val message: String): HomeState
}
class HomeViewModel(
private val loadHome: LoadHomeUseCase
) : ViewModel() {
private val _state = MutableStateFlow<HomeState>(HomeState.Loading)
val state: StateFlow<HomeState> = _state
fun reload() = viewModelScope.launch {
_state.value = HomeState.Loading
runCatching { loadHome.execute() }
.onSuccess { _state.value = HomeState.Data(it) }
.onFailure { _state.value = HomeState.Error(it.message ?: "unknown") }
}
}
-
スコープ寿命:
viewModelScopeに束縛(画面破棄で自動キャンセル) - 例外整形:VMでユーザ向けメッセージに変換 or UseCaseでドメインエラーに変換してもOK
10.6 テスト(仮想時間 × 非決定性排除)
10.6.1 UseCase:タイムアウトと並列合流の検証
@OptIn(ExperimentalCoroutinesApi::class)
class LoadHomeUseCaseTest {
private val dispatcher = StandardTestDispatcher()
private val scope = TestScope(dispatcher)
@Before fun setUp() = Dispatchers.setMain(dispatcher)
@After fun tearDown() = Dispatchers.resetMain()
@Test fun `timeout triggers cancellation`() = scope.runTest {
val news = object : NewsRepository {
override suspend fun fetchTop(): List<Article> { delay(5_000); return emptyList() }
}
val users = object : UserRepository {
override suspend fun fetchProfile(): Profile { delay(1_000); return Profile("u1","Anna") }
}
val reco = object : RecommendRepository {
override suspend fun recommendFor(profile: Profile): Recommendation { return Recommendation(listOf()) }
}
val uc = LoadHomeUseCase(news, users, reco, default = dispatcher)
val job = async { runCatching { uc.execute(timeoutMs = 2_000) }.isSuccess }
advanceTimeBy(2_000) // 仮想時間でタイムアウト発火
runCurrent()
assertFalse(job.await()) // 失敗(TimeoutCancellationException)
}
@Test fun `parallel awaits merge correctly`() = scope.runTest {
val news = object : NewsRepository {
override suspend fun fetchTop(): List<Article> { delay(500); return listOf(Article("1","A")) }
}
val users = object : UserRepository {
override suspend fun fetchProfile(): Profile { delay(500); return Profile("u1","Anna") }
}
val reco = object : RecommendRepository {
override suspend fun recommendFor(profile: Profile): Recommendation { delay(500); return Recommendation(listOf("X")) }
}
val uc = LoadHomeUseCase(news, users, reco, default = dispatcher)
val deferred = async { uc.execute(timeoutMs = 5_000) }
advanceUntilIdle() // すべて完了まで進める
val model = deferred.await()
assertEquals("A", model.articles.first().title)
assertEquals("Anna", model.profile.name)
assertEquals(listOf("X"), model.recommendation.items)
}
}
まとめ
| 観点 | 最適粒度の方向性 |
|---|---|
| ドメイン | 粗く(安定性重視) |
| ユースケース | 中粒度(変更単位) |
| データアクセス / UI | 細かく(柔軟性・再利用重視) |
| 通信境界(API, Flow, Coroutine) | コストを見て調整 |
粒度設計は「技術」ではなく「バランス感覚」
過剰分割も、過剰統合も避けよ
その中間に、拡張と安定の調和がある