[Xcode/Swift] iOSアーキテクチャ完全入門、有名どころの概念をざっくり理解する

1. はじめに

1.1 なぜアーキテクチャが必要?

  • 保守性:時間が経っても壊れにくい
  • テスト容易性:仕様を自動で守らせる
  • チーム開発:責務が明確=衝突が減る

本記事のゴール:

各アーキテクチャの向き不向きと考え方を掴んで、明日からの小さな実装に一歩取り入れられること。完璧主義は不要。まずは「どんな設計思想か」を自分の言葉で理解 & 説明できればOK。

1.2 今回扱うアーキテクチャ

  • MVC (Model / View / Controller)
  • MVVM (Model / View / ViewModel)
  • VIPER (View / Interactor / Presenter / Entity / Router)
  • Clean Architecture
  • TCA (The Composable Architecture)

2. 共通基礎:データフローと言葉の整理

  • State:画面に映る値(例:count = 0
  • Action(Event):ユーザーの操作や外部からの入力(例:ボタンタップ)
  • Effect(副作用):ネットワーク・DB・タイマーなど、外界とのやり取り
  • 依存関係:上位(UI)→下位(ドメイン)へ向けるほどテストしやすい

比喩:レストラン

  • 客=View、注文=Action、キッチン=ロジック、仕入れ=副作用
  • 忙しくなるほど「誰が何をするか」を分けた方が早くてミスが減る=アーキテクチャの存在意義

3. MVC(Model–View–Controller)

最短で動く伝統的スタイル。UIKit では自然、SwiftUI では MVVM に寄りやすい。

3.1 構成

MyApp
│
├── Model                ← アプリのデータ構造・状態(ビジネスロジックも含むことあり)
│   └── SampleEntity.swift
│
├── View                 ← UI画面(SwiftUI View / UIKit View)
│   └── SampleView.swift
│
├── Controller           ← 入力処理・状態更新・画面遷移など
│   └── SampleViewController.swift  (UIKit中心)
│
├── Service              ← API通信、永続化、その他外部依存処理
│   └── SampleService.swift
│
├── Resource             ← Assets, Localizable.strings, 設定ファイルなど
│   └── ...
レイヤ名役割の概要具体的な中身例
Modelアプリの状態やデータ構造を定義。ロジックを含めることもありSampleEntity.swift, Item.swift
View画面UIを定義。ユーザー入力と表示を担当SampleView.swift(SwiftUI)/ Storyboard画面など
ControllerViewとModelの中間。UIイベント処理・画面遷移などを担当SampleViewController.swift, AppCoordinator.swift
ServiceAPI呼び出しやローカルDBアクセスなど外部連携処理(必要に応じて)SampleService.swift, NetworkClient.swift

※ 落とし穴:

  • あれもこれも Controller に詰め込み→Fat ViewControllerになりがち

3.2 例(UIKit)

// Model
struct Counter {
    var value = 0
}

// Controller
final class CounterViewController: UIViewController {
    private var counter = Counter()
    @IBOutlet private weak var label: UILabel!

    @IBAction private func plusTapped(_ sender: UIButton) {
        counter.value += 1
        label.text = "\(counter.value)"
    }
}

3.3 SwiftUI での注意

SwiftUI には ViewController がないため、ロジックをどこに置くかでMVVMへ自然に移行しがち。
小規模なら @State/@StateObject で十分。ただし複雑化したら次章の MVVM を検討。


4. MVVM(Model–View–ViewModel)

UI ロジックを ViewModel に寄せて View を薄くする。SwiftUI と相性◎。

4.1 構成

MyApp
│
├── Model                  ← データ構造。ビジネスルールを含む場合もあり
│   └── SampleEntity.swift
│
├── View                   ← 画面表示(SwiftUI View)
│   └── SampleView.swift
│
├── ViewModel              ← ユーザー操作・状態管理・画面ロジック
│   └── SampleViewModel.swift
│
├── Service                ← APIアクセス、DB操作などの外部連携処理
│   └── SampleService.swift
│
├── Resource               ← ローカライズ、画像、設定ファイルなど
│   └── ...
レイヤ名役割の概要具体的な中身例
Modelアプリ内で扱うデータ構造や状態を定義。ビジネスルールを持つこともあるSampleEntity.swift, Item.swift
Viewユーザーに見せる画面UI(SwiftUI View)。ViewModelから状態を受け取り表示するSampleView.swift, ItemListView.swift
ViewModelUIイベントを受け取り、状態を更新する中継役。非同期処理やロジックの調整を担当SampleViewModel.swift, ItemViewModel.swift
ServiceネットワークやDBとのやり取りなど、外部との通信を担う実装層SampleService.swift, NetworkClient.swift

※ 落とし穴:肥大化する ViewModel → 入出力を分ける、機能ごとに VM を分割 (RxSwift, Combineをうまく活用)

4.2 サンプルコード

import SwiftUI

// Model、今回はシンプルなので省略
struct Counter {
    var value = 0
}

// ViewModel
final class CounterViewModel: ObservableObject {
    @Published private(set) var value = 0

    func increment() {
        value += 1
    }
    func decrement() {
        value -= 1
    }
}

// View
struct CounterView: View {
    @StateObject private var vm = CounterViewModel()

    var body: some View {
        VStack(spacing: 16) {
            Text("\(vm.value)").font(.largeTitle.monospacedDigit())
            HStack {
                Button("−") {
                    vm.decrement()
                }
                Button("+") {
                    vm.increment()
                }
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

5. Clean Architecture

ユースケース中心。**依存関係逆転(DIP)**で内側(ドメイン)を守る。

5.1 構成

MyApp
│
├── Presentation                 ← UIレイヤー(SwiftUI / UIKit)
│   ├── View                     ← 画面UI(HomeView, DetailView など)
│   │   └── SampleView.swift
│   ├── ViewModel                ← ユーザー操作や状態管理
│   │   └── SampleViewModel.swift
│   └── UIComponent              ← 共通UIパーツ(カスタムButton等)
│       └── SampleRow.swift
│
├── Domain                       ← ビジネスロジック層
│   ├── Model                    ← Entity。アプリ内部のデータ型
│   │   └── SampleEntity.swift
│   ├── UseCase                  ← ユースケース(処理の本体)
│   │   └── FetchSampleUseCase.swift
│   └── Repository               ← データ取得の抽象インターフェース
│       └── SampleRepository.swift
│
├── Data                         ← 実装層(API・DBなど)
│   ├── RepositoryImpl           ← Repository の具象実装
│   │   └── SampleRepositoryImpl.swift
│   ├── APIClient                ← ネットワーク通信
│   │   └── SampleAPIClient.swift
│   └── DTO                      ← APIレスポンス等の中間データ
│       └── SampleResponseDTO.swift
│
├── Core / Shared                ← 共通機能(エラー, DI, 拡張など)
│   ├── AppError.swift
│   ├── DIContainer.swift
│   └── DateFormatter+Ext.swift
レイヤ名役割の概要具体的な中身例
PresentationUI表示・ユーザー操作の受付。アプリの「見た目」と「操作感」を司る部分SwiftUI View、ViewModel、Router
Domainこのアプリが何をしたいのか(=本質的な振る舞い)を書く場所Entity(データ構造)、UseCase(操作)、RepositoryのProtocol
Data外部データとの接続。APIやDBから情報を取って、Domain層に渡す実装部Repositoryの実装、APIClient、DTO
Core / Sharedアプリ全体で使う共通処理・仕組み。どのレイヤにも依存せず使えるエラー定義、DI設定、拡張メソッド、ユーティリティなど

5.2 サンプルコード

import SwiftUI
import Foundation

// Entity
struct Todo: Identifiable, Equatable {
    let id: UUID
    var title: String
    var done: Bool
}

// UseCase の境界
protocol AddTodoUseCase {
    func add(title: String) async throws -> Todo
}

// Repository の境界
protocol TodoRepository {
    func create(title: String) async throws -> Todo
}

// UseCase 実装(ドメイン層)
struct AddTodoInteractor: AddTodoUseCase {
    let repo: TodoRepository

    func add(title: String) async throws -> Todo {
        // ビジネスルールをここに
        guard !title.trimmingCharacters(in: .whitespaces).isEmpty else {
            throw ValidationError.emptyTitle
        }
        return try await repo.create(title: title)
    }
}

enum ValidationError: Error {
    case emptyTitle
}

// Infra 実装(データ層)
final class InMemoryTodoRepository: TodoRepository {
    private var storage: [Todo] = []
    func create(title: String) async throws -> Todo {
        let todo = Todo(id: UUID(), title: title, done: false)
        storage.append(todo)
        return todo
    }
}

// Presenter/ViewModel(UI 境界)
@MainActor
final class TodoViewModel: ObservableObject {
    @Published var todos: [Todo] = []
    @Published var error: String?

    private let addTodo: AddTodoUseCase

    init(addTodo: AddTodoUseCase) {
        self.addTodo = addTodo
    }

    func add(title: String) {
        Task {
            do {
                let new = try await addTodo.add(title: title); todos.append(new)
            }
            catch {
                self.error = "\(error)"
            }
        }
    }
}

// SwiftUI View(Frameworks 層)
struct TodoView: View {
    @StateObject private var vm: TodoViewModel
    @State private var title = ""

    init() {
        let repo = InMemoryTodoRepository()
        let uc = AddTodoInteractor(repo: repo)
        _vm = StateObject(wrappedValue: TodoViewModel(addTodo: uc))
    }

    var body: some View {
        VStack {
            HStack {
                TextField("何する?", text: $title)
                Button("追加") { vm.add(title: title); title = "" }
            }
            List(vm.todos) { Text($0.title) }
        }
        .padding()
    }
}

ポイント:

  • 境界は Protocol で定義 → UI なしで UseCase をテストできる
  • 外部の事情(DB/HTTP)を内側に漏らさない

6. TCA(The Composable Architecture)

単方向データフローを厳密に表現し、**合成(Composable)**で大規模でも破綻しにくい。

6.1 構成

MyApp
│
├── Feature                ← 各画面・機能単位のロジック(State / Action / Reducer / View)
│   ├── SampleFeature.swift
│   └── AnotherFeature.swift
│
├── View                   ← 必要に応じて View だけ分離(スタイルや共通UI向け)
│   └── SharedButton.swift
│
├── Model                  ← 共通データ構造(Entity 的なもの)
│   └── SampleEntity.swift
│
├── APIClient              ← 副作用(Effect)として扱う外部依存(API/DBなど)
│   └── SampleAPIClient.swift
│
├── Dependency             ← DI設定(live/testの差し替え)
│   └── SampleDependency.swift
│
├── Resource               ← Assets, strings, config 等
│   └── ...
ファイル/ディレクトリ役割の概要具体的な中身例
Feature機能単位のロジックを定義。State / Action / Reducer / View を含むSampleFeature.swift, CounterFeature.swift
View(任意)スタイリングや再利用性に特化したView群(特定のFeatureに依存しない)SharedButton.swift, ListRowView.swift
Modelデータ構造(Entity)。Stateとして使われるSampleEntity.swift, Item.swift
APIClient(Effect)非同期処理・副作用を提供。Reducerから呼び出されるSampleAPIClient.swift, TodoService.swift
DependencyDIの仕組み。Live / Mock などの注入制御SampleDependency.swift, .live, .test の定義

6.2 サンプルコード

import SwiftUI
import ComposableArchitecture

@Reducer
struct CounterFeature {
    struct State: Equatable { var value = 0 }
    enum Action: Equatable {
        case increment
        case decrement
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .increment:
                state.value += 1
                return .none
            case .decrement:
                state.value -= 1
                return .none
            }
        }
    }
}

struct CounterView: View {
    let store: StoreOf<CounterFeature>
    var body: some View {
        WithPerceptionTracking {
            let state = store.state
            VStack(spacing: 16) {
                Text("\(state.value)").font(.largeTitle.monospacedDigit())
                HStack {
                    Button("−") { store.send(.decrement) }
                    Button("+") { store.send(.increment) }
                }
                .buttonStyle(.borderedProminent)
            }
            .padding()
        }
    }
}

// どこかのエントリポイント
// CounterView(store: Store(initialState: .init()) { CounterFeature() })

ポイント:

テストのしやすさ:Reducer は「入力(Action)→出力(State変化/Effect)」が明確で、ユニットテストが書きやすい。


7. VIPER

画面単位で責務を厳格に分離するパターン。特に**画面遷移の責務(Router)**が明確。

7.1 構成

MyApp
│
├── Module
│   └── SampleFeature                  ← 機能・画面単位のモジュール
│       ├── View                       ← UI表示(SwiftUI / UIKit)
│       │   └── SampleView.swift
│       ├── Presenter                  ← Viewとの橋渡し。状態管理・画面ロジック
│       │   └── SamplePresenter.swift
│       ├── Interactor                 ← ビジネスロジック処理
│       │   └── SampleInteractor.swift
│       ├── Entity                     ← データ構造
│       │   └── SampleEntity.swift
│       ├── Router                     ← 遷移処理
│       │   └── SampleRouter.swift
│       └── Protocol                   ← 各パーツのインターフェース定義
│           └── SampleProtocols.swift
│
├── Shared
│   └── Component                      ← 共通UIパーツなど
│       └── SharedButton.swift
│
├── Resource                           ← Assets、strings、設定ファイルなど
│   └── ...
レイヤ名役割の概要具体的な中身例
ViewUI表示とイベント受け取り。Presenterのみに依存SampleView.swift, SampleViewController.swift
PresenterViewとInteractorの中継。状態管理・画面ロジックを担当SamplePresenter.swift
Interactorビジネスロジックの実行。データ処理やユースケース実行SampleInteractor.swift
Entityアプリ内部のデータ構造(ドメインモデル)SampleEntity.swift
Router画面遷移の責務を持つ。次の画面を組み立てる構造を明確にSampleRouter.swift
Protocol各レイヤのやりとりを定義するインターフェース群SampleProtocols.swift

7.2 サンプルコード

import SwiftUI

// MARK: - Entity
struct CounterEntity {
    var value = 0
}

// MARK: - Interactor
protocol CounterUseCase {
    func increment(_ entity: inout CounterEntity)
}
struct CounterInteractor: CounterUseCase {
    func increment(_ entity: inout CounterEntity) {
        entity.value += 1
    }
}

// MARK: - Router
protocol CounterRouterInput {
    func routeToResult(value: Int)
}
final class CounterRouter: CounterRouterInput, ObservableObject {
    @Published var isShowingResult = false
    var passedValue: Int = 0

    func routeToResult(value: Int) {
        passedValue = value
        isShowingResult = true
    }
}

// MARK: - Presenter
@MainActor
final class CounterPresenter: ObservableObject {
    @Published var displayText: String = "0"

    private var entity = CounterEntity()
    private let interactor: CounterUseCase
    private let router: CounterRouterInput

    init(interactor: CounterUseCase, router: CounterRouterInput) {
        self.interactor = interactor
        self.router = router
    }

    func didTapPlus() {
        interactor.increment(&entity)
        displayText = "\(entity.value)"
    }

    func didTapShowResult() {
        router.routeToResult(value: entity.value)
    }
}

// MARK: - View
struct CounterVIPERView: View {
    @StateObject private var router = CounterRouter()
    @StateObject private var presenter: CounterPresenter

    init() {
        let router = CounterRouter()
        _router = StateObject(wrappedValue: router)
        _presenter = StateObject(wrappedValue: CounterPresenter(
            interactor: CounterInteractor(),
            router: router
        ))
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                Text(presenter.displayText)
                    .font(.largeTitle.monospacedDigit())
                Button("+") {
                    presenter.didTapPlus()
                }
                Button("結果を見る") {
                    presenter.didTapShowResult()
                }

                NavigationLink(
                    destination: ResultView(value: router.passedValue),
                    isActive: $router.isShowingResult
                ) {
                    EmptyView()
                }
            }
            .padding()
        }
    }
}

// MARK: - 遷移先 View(Result)
struct ResultView: View {
    let value: Int
    var body: some View {
        VStack(spacing: 20) {
            Text("最終カウント")
            Text("\(value)")
                .font(.largeTitle.monospacedDigit())
        }
        .navigationTitle("結果")
    }
}

ポイント:

  • ファイル数が増える(テンプレ化・ジェネレータ活用で軽減)
  • 命名・責務がブレると逆効果 → **「入力は Presenter、ユースケースは Interactor、遷移は Router」**を徹底

8. 比較・選定ガイドと実践ステップ

8.1 ざっくり比較(◎:強い / △:普通 / ▲:弱い)

観点MVCMVVMVIPERCleanTCA
学習コスト
実装速度(小規模)
テスト容易性
規模適性(中〜大)
一貫したデータフロー

8.2 どれを選ぶ?

  • 個人・試作・小規模:まず MVVM(最小コストでテストもしやすい)
  • 中規模で機能が増える:MVVM に **Clean の考え(UseCase, Repository 境界)**を一部導入
  • 厳密な状態管理・長期運用TCA または Clean
  • 画面遷移と責務を厳格に分けたいVIPER

8.3 移行の道筋(無理なく段階的に)

  • MVC → View のロジックを ViewModel へ(MVVM)
  • UseCase/Repository の Protocol 境界を導入(Clean っぽく)
  • 状態管理の一貫性が必要になったら TCA へ、画面単位の厳格分業なら VIPER

8.4 テスト観点チェックリスト

  • UI 抜きでビジネスルールをテストできるか?
  • 依存(HTTP/DB)を差し替えられるか?(Protocol + DI)
  • 非同期副作用の結果を待たずにロジックを検証できるか?
  • 仕様変更時、触るファイルが限定されるか?

まとめ

正解は一つではないので、チームや規模に合わせて、責務の分離テスト容易性を少しずつ高めていけば十分。

まずは MVVM から始め、必要に応じて Clean/TCA/VIPER の要素を足していきましょう。


参考リンク