iOSエンジニアリング

[iOSアーキテクチャ #6] Clean Architectureの核心

求人や技術ブログで「Clean Architectureベース」という表現をよく見かけます。しかし実際にコードを見ると、チームごとに形はさまざまです。UseCaseがある場合もない場合もあり、Repositoryの役割も異なります。

読了 5 分
[iOSアーキテクチャ #6] Clean Architectureの核心のカバー画像

求人や技術ブログで「Clean Architectureベース」という表現をよく見かけます。しかし実際にコードを見ると、チームごとに形はさまざまです。UseCaseがある場合もない場合もあり、Repositoryの役割も異なります。

混乱する理由は簡単です。Clean Architectureは特定のフォルダー構成やクラス一覧ではなく、1つの原則だからです。

今回は、その原則とは何か、そしてiOSでUseCaseとRepositoryとして実装するときに、それぞれ何を担当するのかを整理します。


核心は「依存関係は内側にのみ向ける」

Clean ArchitectureはRobert Martin(Uncle Bob)が整理した概念で、アプリを同心円状のレイヤーとして捉えます。

  • 最内層: ドメイン。エンティティとビジネスルール。「このアプリは何をするのか」
  • 中間層: UseCase。アプリの動作シナリオ
  • 外側: UI、データベース、ネットワーク、フレームワーク

ルールは1つだけです。依存関係は常に外側から内側に向かう。

内側のドメインコードは、外側のUIKit、URLSession、SwiftDataを知ってはいけません。逆に外側のレイヤーは内側を知っていても構いません。これにより、UIフレームワークを置き換えてもサーバーAPIが変わっても、アプリの核心となるルールに手を入れずに済みます。

お気づきのとおり、これはDIP(依存性逆転の原則)をアプリ全体の規模に拡張したものです。「具象ではなく抽象に依存する」をレイヤー単位で適用しています。


Repository:「データの出どころ」を隠す境界

Repositoryはドメインとデータソースの境界です。プロトコルはドメイン側に、実装は外側に置きます。

// ドメイン層 — URLSessionは SwiftData何も知らない
protocol PostRepository {
    func fetchPosts(userID: String) async throws -> [Post]
}

// データ層 — 実装は外側に
final class RemotePostRepository: PostRepository {
    func fetchPosts(userID: String) async throws -> [Post] {
        // URLSession 呼び出し, DTO デコード, Post変換
    }
}

ポイントはプロトコルの位置です。インターフェースをドメインが所有するため、ドメインは「投稿を取得できる」ことだけを知り、それがサーバーなのかキャッシュなのかローカルDBなのかは知りません。サーバーのレスポンス形式が変わっても、DTO(Data Transfer Object、サーバーレスポンスを運ぶデータ転送オブジェクト)と実装だけを直せば済みます。テストでは偽のRepositoryを差し込めます。

ドメインはデータの出どころを知る必要がない
ドメインはデータの出どころを知る必要がない

UseCase:「このアプリがすること」を1つ収める器

UseCaseは、アプリの動作シナリオを1つオブジェクトにしたものです。「フィードを読み込む」「投稿を公開する」といった単位です。

final class LoadFeedUseCase {
    private let postRepository: PostRepository
    private let blockRepository: BlockRepository

    func execute(userID: String) async throws -> [Post] {
        let posts = try await postRepository.fetchPosts(userID: userID)
        let blocked = try await blockRepository.blockedUserIDs()
        return posts
            .filter { !blocked.contains($0.authorID) }
            .sorted { $0.createdAt > $1.createdAt }
    }
}

「ブロックしたユーザーの投稿をフィードから除外する」というビジネスルールはUseCaseに置きます。これをViewModelに置くとどうなるでしょうか。フィード、プロフィール、検索の各画面がそれぞれブロックフィルターを実装し、いずれどこかでずれます。UseCaseに切り出せば、ルールを1か所に集約でき、画面なしでテストできます。

そのため、MVVM(Model-View-ViewModel)とClean Architectureは競合する関係ではなく、直交する関係です。MVVMはView側の整理法で、Clean Architectureはその後ろにあるレイヤーの整理法です。前回「Massive ViewModelを避けるにはロジックを下位へ移す」と述べましたが、その移し先がUseCaseとRepositoryです。


どこまで導入すべきでしょうか?

Clean Architectureの落とし穴は過剰導入です。2画面だけのアプリにUseCase・Repository・DTO・Mapperをすべて揃えると、値を1つ渡すだけで5つのファイルを通ります。VIPER(View・Interactor・Presenter・Entity・Router)のボイラープレート問題がそのまま再現されます。

現実的な基準は次のとおりです。

  • **Repositoryはほぼ常に効果があります。**ネットワークやDBのコードを画面から分離するだけで、テストと変更が容易になります。
  • **複数の画面で共有するビジネスルールが生まれたらUseCaseを導入します。**Repositoryの呼び出しをそのまま渡すだけのUseCaseが大半なら、まだ早いでしょう。
  • **レイヤーごとのモデル分離(DTO/ドメイン/画面モデル)は、規模が大きくなってから行います。**最初からすべて分けると、Mapperコードだけが増えていきます。
すべて揃えるのではなく、必要になったら1層ずつ積み上げます
すべて揃えるのではなく、必要になったら1層ずつ積み上げます

まとめ

  • Clean Architectureはフォルダー構成ではなく、「依存関係は内側(ドメイン)にのみ向ける」という原則です。DIPをアプリ規模に拡張したものです。
  • Repositoryはデータの出どころを隠す境界であり、プロトコルをドメインが所有する点が核心です。
  • UseCaseは、複数の画面で共有するビジネスルールを置く場所です。MVVMとは直交するため、併用できます。
  • すべて揃えることが目標ではありません。まずRepositoryから始め、ルールが増えたらUseCaseを追加しましょう。

次回は方向を変え、状態管理そのものをアーキテクチャにしたTCA(The Composable Architecture)を扱います。