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 に寄りやすい。
- Model:データとビジネスルール
- View:見た目(UIKit:
UIView
/ SwiftUI:View
) - Controller:入力を受けて Model を更新し View を更新
※ 落とし穴:
- あれもこれも Controller に詰め込み→Fat ViewControllerになりがち
3.1 例(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.2 SwiftUI での注意
SwiftUI には ViewController がないため、ロジックをどこに置くかでMVVMへ自然に移行しがち。
小規模なら @State
/@StateObject
で十分。ただし複雑化したら次章の MVVM を検討。
4. MVVM(Model–View–ViewModel)
UI ロジックを ViewModel に寄せて View を薄くする。SwiftUI と相性◎。
- ViewModel:表示用の状態と振る舞いを提供(
ObservableObject
) - View:
@StateObject/@ObservedObject
で ViewModel を監視 - 効果:UI なしでも ViewModel をテストできる
※ 落とし穴:肥大化する ViewModel → 入出力を分ける、機能ごとに VM を分割 (RxSwift, Combineをうまく活用)
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)**で内側(ドメイン)を守る。
- Entities:ビジネスルール(純粋な型)
- UseCases(Interactor):アプリ固有の振る舞い
- Interface Adapters:Presenter/ViewModel、Repository 実装
- Frameworks:UI・DB・HTTP 等の外部
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)**で大規模でも破綻しにくい。
- State:画面の状態
- Action:イベント
- Reducer:State と Action を受けて次の State と Effect を返す
- Store:状態と副作用のハブ
- 合成:小さな Feature を組み合わせて大きな画面へ
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)**が明確。
- View:表示とユーザー入力の受け取り
- Interactor:ユースケースの実行(ドメイン)
- Presenter:表示用に整形・イベント中継(SwiftUI では
ObservableObject
化しやすい) - Entity:ドメインのデータ
- Router(Wireframe):画面遷移・組み立て
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/