0
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?

【Kotlin】並行処理 × 粒度設計

Posted at

はじめに

〜モジュール分割・並行処理・アーキテクチャの最適バランス〜

「どこまで細かく分けるべきか?」
それがすべての設計の出発点であり、終着点。


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 キャンセル境界を粒度に合わせる

「キャンセルできる単位」でスコープを分ける。
coroutineScopesupervisorScopeの選択も、粒度境界と一致させる。


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()
                )
            }
        }
}

補足

  • withTimeoutUseCase 粒度で適用し、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) コストを見て調整

粒度設計は「技術」ではなく「バランス感覚」
過剰分割も、過剰統合も避けよ
その中間に、拡張と安定の調和がある

0
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
0
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?