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画面など |
Controller | ViewとModelの中間。UIイベント処理・画面遷移などを担当 | SampleViewController.swift , AppCoordinator.swift |
Service | API呼び出しやローカル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 |
ViewModel | UIイベントを受け取り、状態を更新する中継役。非同期処理やロジックの調整を担当 | 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
レイヤ名 | 役割の概要 | 具体的な中身例 |
---|---|---|
Presentation | UI表示・ユーザー操作の受付。アプリの「見た目」と「操作感」を司る部分 | 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 |
Dependency | DIの仕組み。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、設定ファイルなど
│ └── ...
レイヤ名 | 役割の概要 | 具体的な中身例 |
---|---|---|
View | UI表示とイベント受け取り。Presenterのみに依存 | SampleView.swift , SampleViewController.swift |
Presenter | Viewと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 ざっくり比較(◎:強い / △:普通 / ▲:弱い)
観点 | MVC | MVVM | VIPER | Clean | TCA |
---|---|---|---|---|---|
学習コスト | ◎ | ◎ | △ | △ | △ |
実装速度(小規模) | ◎ | ◎ | △ | △ | △ |
テスト容易性 | ▲ | ○ | ○ | ◎ | ◎ |
規模適性(中〜大) | ▲ | ○ | ○ | ◎ | ◎ |
一貫したデータフロー | ▲ | ○ | ○ | ○ | ◎ |
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 の要素を足していきましょう。
参考リンク
- SwiftUI Data Flow(
State
,Binding
,ObservableObject
など)
https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app - Concurrency(
async/await
)
https://developer.apple.com/documentation/swift/swift_standard_library/concurrency - Xcode Unit Testing
https://developer.apple.com/documentation/xcode/writing-tests-in-xcode - Protocol-Oriented Programming in Swift(設計の土台に)
https://developer.apple.com/videos/play/wwdc2016/419/ - Clean Architecture(Robert C. Martin 概念)
https://8thlight.com/insights/clean-architecture - The Composable Architecture(Point-Free / GitHub)
https://github.com/pointfreeco/swift-composable-architecture - VIPER 解説(objc.io “Architecting iOS Apps with VIPER”)
https://www.objc.io/issues/13-architecture/viper/