@kata-sh/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/app-paths.d.ts +4 -0
- package/dist/app-paths.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +56 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +95 -0
- package/dist/resource-loader.d.ts +18 -0
- package/dist/resource-loader.js +50 -0
- package/dist/wizard.d.ts +15 -0
- package/dist/wizard.js +159 -0
- package/package.json +50 -21
- package/pkg/dist/modes/interactive/theme/dark.json +85 -0
- package/pkg/dist/modes/interactive/theme/light.json +84 -0
- package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/pkg/dist/modes/interactive/theme/theme.js +949 -0
- package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
- package/pkg/package.json +8 -0
- package/scripts/postinstall.js +45 -0
- package/src/resources/AGENTS.md +108 -0
- package/src/resources/KATA-WORKFLOW.md +661 -0
- package/src/resources/agents/researcher.md +29 -0
- package/src/resources/agents/scout.md +56 -0
- package/src/resources/agents/worker.md +31 -0
- package/src/resources/extensions/ask-user-questions.ts +200 -0
- package/src/resources/extensions/bg-shell/index.ts +2758 -0
- package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
- package/src/resources/extensions/browser-tools/core.js +1057 -0
- package/src/resources/extensions/browser-tools/index.ts +4916 -0
- package/src/resources/extensions/browser-tools/package.json +20 -0
- package/src/resources/extensions/context7/index.ts +428 -0
- package/src/resources/extensions/context7/package.json +11 -0
- package/src/resources/extensions/get-secrets-from-user.ts +352 -0
- package/src/resources/extensions/github/formatters.ts +207 -0
- package/src/resources/extensions/github/gh-api.ts +537 -0
- package/src/resources/extensions/github/index.ts +778 -0
- package/src/resources/extensions/kata/activity-log.ts +88 -0
- package/src/resources/extensions/kata/auto.ts +2786 -0
- package/src/resources/extensions/kata/commands.ts +355 -0
- package/src/resources/extensions/kata/crash-recovery.ts +85 -0
- package/src/resources/extensions/kata/dashboard-overlay.ts +516 -0
- package/src/resources/extensions/kata/docs/preferences-reference.md +103 -0
- package/src/resources/extensions/kata/doctor.ts +683 -0
- package/src/resources/extensions/kata/files.ts +730 -0
- package/src/resources/extensions/kata/gitignore.ts +165 -0
- package/src/resources/extensions/kata/guided-flow.ts +976 -0
- package/src/resources/extensions/kata/index.ts +556 -0
- package/src/resources/extensions/kata/metrics.ts +397 -0
- package/src/resources/extensions/kata/observability-validator.ts +408 -0
- package/src/resources/extensions/kata/package.json +11 -0
- package/src/resources/extensions/kata/paths.ts +346 -0
- package/src/resources/extensions/kata/preferences.ts +695 -0
- package/src/resources/extensions/kata/prompt-loader.ts +50 -0
- package/src/resources/extensions/kata/prompts/complete-milestone.md +25 -0
- package/src/resources/extensions/kata/prompts/complete-slice.md +27 -0
- package/src/resources/extensions/kata/prompts/discuss.md +151 -0
- package/src/resources/extensions/kata/prompts/doctor-heal.md +29 -0
- package/src/resources/extensions/kata/prompts/execute-task.md +64 -0
- package/src/resources/extensions/kata/prompts/guided-complete-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-milestone.md +3 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-slice.md +59 -0
- package/src/resources/extensions/kata/prompts/guided-execute-task.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-plan-milestone.md +23 -0
- package/src/resources/extensions/kata/prompts/guided-plan-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-research-slice.md +11 -0
- package/src/resources/extensions/kata/prompts/guided-resume-task.md +1 -0
- package/src/resources/extensions/kata/prompts/plan-milestone.md +47 -0
- package/src/resources/extensions/kata/prompts/plan-slice.md +63 -0
- package/src/resources/extensions/kata/prompts/queue.md +85 -0
- package/src/resources/extensions/kata/prompts/reassess-roadmap.md +48 -0
- package/src/resources/extensions/kata/prompts/replan-slice.md +39 -0
- package/src/resources/extensions/kata/prompts/research-milestone.md +37 -0
- package/src/resources/extensions/kata/prompts/research-slice.md +28 -0
- package/src/resources/extensions/kata/prompts/run-uat.md +109 -0
- package/src/resources/extensions/kata/prompts/system.md +341 -0
- package/src/resources/extensions/kata/session-forensics.ts +550 -0
- package/src/resources/extensions/kata/skill-discovery.ts +137 -0
- package/src/resources/extensions/kata/state.ts +509 -0
- package/src/resources/extensions/kata/templates/context.md +76 -0
- package/src/resources/extensions/kata/templates/decisions.md +8 -0
- package/src/resources/extensions/kata/templates/milestone-summary.md +73 -0
- package/src/resources/extensions/kata/templates/plan.md +133 -0
- package/src/resources/extensions/kata/templates/preferences.md +15 -0
- package/src/resources/extensions/kata/templates/project.md +31 -0
- package/src/resources/extensions/kata/templates/reassessment.md +28 -0
- package/src/resources/extensions/kata/templates/requirements.md +81 -0
- package/src/resources/extensions/kata/templates/research.md +46 -0
- package/src/resources/extensions/kata/templates/roadmap.md +118 -0
- package/src/resources/extensions/kata/templates/slice-context.md +58 -0
- package/src/resources/extensions/kata/templates/slice-summary.md +99 -0
- package/src/resources/extensions/kata/templates/state.md +19 -0
- package/src/resources/extensions/kata/templates/task-plan.md +52 -0
- package/src/resources/extensions/kata/templates/task-summary.md +57 -0
- package/src/resources/extensions/kata/templates/uat.md +54 -0
- package/src/resources/extensions/kata/tests/activity-log-prune.test.ts +327 -0
- package/src/resources/extensions/kata/tests/auto-preflight.test.ts +97 -0
- package/src/resources/extensions/kata/tests/auto-supervisor.test.mjs +53 -0
- package/src/resources/extensions/kata/tests/complete-milestone.test.ts +317 -0
- package/src/resources/extensions/kata/tests/cost-projection.test.ts +160 -0
- package/src/resources/extensions/kata/tests/derive-state-deps.test.ts +477 -0
- package/src/resources/extensions/kata/tests/derive-state.test.ts +1013 -0
- package/src/resources/extensions/kata/tests/doctor.test.ts +718 -0
- package/src/resources/extensions/kata/tests/idle-recovery.test.ts +490 -0
- package/src/resources/extensions/kata/tests/metrics-io.test.ts +254 -0
- package/src/resources/extensions/kata/tests/metrics.test.ts +217 -0
- package/src/resources/extensions/kata/tests/must-have-parser.test.ts +309 -0
- package/src/resources/extensions/kata/tests/parsers.test.ts +1257 -0
- package/src/resources/extensions/kata/tests/plan-milestone.test.ts +185 -0
- package/src/resources/extensions/kata/tests/plan-quality-validator.test.ts +386 -0
- package/src/resources/extensions/kata/tests/reassess-prompt.test.ts +208 -0
- package/src/resources/extensions/kata/tests/replan-slice.test.ts +686 -0
- package/src/resources/extensions/kata/tests/requirements.test.ts +151 -0
- package/src/resources/extensions/kata/tests/resolve-ts-hooks.mjs +17 -0
- package/src/resources/extensions/kata/tests/resolve-ts.mjs +11 -0
- package/src/resources/extensions/kata/tests/run-uat.test.ts +383 -0
- package/src/resources/extensions/kata/tests/unit-runtime.test.ts +388 -0
- package/src/resources/extensions/kata/tests/workspace-index.test.ts +118 -0
- package/src/resources/extensions/kata/tests/worktree.test.ts +222 -0
- package/src/resources/extensions/kata/types.ts +159 -0
- package/src/resources/extensions/kata/unit-runtime.ts +163 -0
- package/src/resources/extensions/kata/workspace-index.ts +203 -0
- package/src/resources/extensions/kata/worktree.ts +182 -0
- package/src/resources/extensions/mac-tools/index.ts +852 -0
- package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
- package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
- package/src/resources/extensions/search-the-web/cache.ts +78 -0
- package/src/resources/extensions/search-the-web/format.ts +258 -0
- package/src/resources/extensions/search-the-web/http.ts +238 -0
- package/src/resources/extensions/search-the-web/index.ts +68 -0
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +404 -0
- package/src/resources/extensions/search-the-web/tool-search.ts +503 -0
- package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
- package/src/resources/extensions/shared/confirm-ui.ts +126 -0
- package/src/resources/extensions/shared/interview-ui.ts +822 -0
- package/src/resources/extensions/shared/next-action-ui.ts +235 -0
- package/src/resources/extensions/shared/progress-widget.ts +282 -0
- package/src/resources/extensions/shared/thinking-widget.ts +107 -0
- package/src/resources/extensions/shared/ui.ts +400 -0
- package/src/resources/extensions/shared/wizard-ui.ts +551 -0
- package/src/resources/extensions/slash-commands/audit.ts +92 -0
- package/src/resources/extensions/slash-commands/create-extension.ts +375 -0
- package/src/resources/extensions/slash-commands/create-slash-command.ts +280 -0
- package/src/resources/extensions/slash-commands/index.ts +12 -0
- package/src/resources/extensions/slash-commands/kata-run.ts +34 -0
- package/src/resources/extensions/subagent/agents.ts +126 -0
- package/src/resources/extensions/subagent/index.ts +1293 -0
- package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
- package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
- package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
- package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
- package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
- package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
- package/src/resources/skills/frontend-design/SKILL.md +45 -0
- package/src/resources/skills/swiftui/SKILL.md +208 -0
- package/src/resources/skills/swiftui/references/animations.md +921 -0
- package/src/resources/skills/swiftui/references/architecture.md +1561 -0
- package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
- package/src/resources/skills/swiftui/references/navigation.md +1492 -0
- package/src/resources/skills/swiftui/references/networking-async.md +214 -0
- package/src/resources/skills/swiftui/references/performance.md +1706 -0
- package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
- package/src/resources/skills/swiftui/references/state-management.md +1443 -0
- package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
- package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
- package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
- package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
- package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
- package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
- package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
- package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
- package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
- package/dist/commands/task.d.ts +0 -9
- package/dist/commands/task.d.ts.map +0 -1
- package/dist/commands/task.js +0 -129
- package/dist/commands/task.js.map +0 -1
- package/dist/commands/task.test.d.ts +0 -2
- package/dist/commands/task.test.d.ts.map +0 -1
- package/dist/commands/task.test.js +0 -169
- package/dist/commands/task.test.js.map +0 -1
- package/dist/e2e/task-e2e.test.d.ts +0 -2
- package/dist/e2e/task-e2e.test.d.ts.map +0 -1
- package/dist/e2e/task-e2e.test.js +0 -173
- package/dist/e2e/task-e2e.test.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -93
- package/dist/index.js.map +0 -1
- package/dist/slug.d.ts +0 -2
- package/dist/slug.d.ts.map +0 -1
- package/dist/slug.js +0 -12
- package/dist/slug.js.map +0 -1
- package/dist/slug.test.d.ts +0 -2
- package/dist/slug.test.d.ts.map +0 -1
- package/dist/slug.test.js +0 -32
- package/dist/slug.test.js.map +0 -1
|
@@ -0,0 +1,1561 @@
|
|
|
1
|
+
<overview>
|
|
2
|
+
SwiftUI architecture determines how you organize code, manage dependencies, and structure your app for maintainability and scalability. This file covers architectural patterns, project organization, and design decisions.
|
|
3
|
+
|
|
4
|
+
**Read this when:**
|
|
5
|
+
- Starting a new SwiftUI project
|
|
6
|
+
- Deciding between MVVM, TCA, or other patterns
|
|
7
|
+
- Structuring a growing codebase
|
|
8
|
+
- Setting up dependency injection
|
|
9
|
+
- Organizing features into modules
|
|
10
|
+
|
|
11
|
+
**Related files:**
|
|
12
|
+
- state-management.md - State ownership and data flow within architectures
|
|
13
|
+
- navigation.md - Navigation patterns for different architectures
|
|
14
|
+
- networking-async.md - Async operations and where they fit architecturally
|
|
15
|
+
- swiftdata.md - Persistence layer integration with architecture
|
|
16
|
+
</overview>
|
|
17
|
+
|
|
18
|
+
<options>
|
|
19
|
+
## Available Approaches
|
|
20
|
+
|
|
21
|
+
<option name="SwiftUI Native (Minimal Architecture)">
|
|
22
|
+
**When to use:** Simple apps with limited business logic, prototypes, learning projects, apps with 5-10 screens or fewer
|
|
23
|
+
|
|
24
|
+
**Strengths:**
|
|
25
|
+
- Zero architectural overhead - just build features
|
|
26
|
+
- SwiftUI has MVVM-like patterns built-in (@State acts as ViewModel)
|
|
27
|
+
- Fastest development for small scopes
|
|
28
|
+
- Easy to understand for SwiftUI beginners
|
|
29
|
+
- Works seamlessly with SwiftData (MVVM struggles with SwiftData)
|
|
30
|
+
|
|
31
|
+
**Weaknesses:**
|
|
32
|
+
- Business logic mixes with views as app grows
|
|
33
|
+
- Hard to test (views contain logic)
|
|
34
|
+
- No clear dependency management strategy
|
|
35
|
+
- Doesn't scale well beyond simple apps
|
|
36
|
+
- Can lead to massive view files
|
|
37
|
+
|
|
38
|
+
**Current status:** Apple's default approach, actively recommended for simple apps
|
|
39
|
+
|
|
40
|
+
**Learning curve:** Easy - just use SwiftUI's built-in patterns
|
|
41
|
+
|
|
42
|
+
```swift
|
|
43
|
+
// Simple counter app with no explicit architecture
|
|
44
|
+
struct ContentView: View {
|
|
45
|
+
@State private var count = 0
|
|
46
|
+
@State private var items: [Item] = []
|
|
47
|
+
|
|
48
|
+
var body: some View {
|
|
49
|
+
VStack {
|
|
50
|
+
Text("Count: \(count)")
|
|
51
|
+
Button("Increment") {
|
|
52
|
+
count += 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
List(items) { item in
|
|
56
|
+
Text(item.name)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
.task {
|
|
60
|
+
items = try? await fetchItems()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Business logic embedded in view
|
|
65
|
+
private func fetchItems() async throws -> [Item] {
|
|
66
|
+
let url = URL(string: "https://api.example.com/items")!
|
|
67
|
+
let (data, _) = try await URLSession.shared.data(from: url)
|
|
68
|
+
return try JSONDecoder().decode([Item].self, from: data)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
</option>
|
|
73
|
+
|
|
74
|
+
<option name="MVVM with @Observable">
|
|
75
|
+
**When to use:** Medium to large apps, apps requiring extensive testing, teams familiar with MVVM, apps with complex business logic
|
|
76
|
+
|
|
77
|
+
**Strengths:**
|
|
78
|
+
- Clear separation between UI (View) and logic (ViewModel)
|
|
79
|
+
- Highly testable - ViewModels are plain Swift classes
|
|
80
|
+
- Industry standard pattern - team members likely know it
|
|
81
|
+
- Works well with dependency injection
|
|
82
|
+
- @Observable (iOS 17+) provides better performance than ObservableObject
|
|
83
|
+
- Scalable to large codebases
|
|
84
|
+
|
|
85
|
+
**Weaknesses:**
|
|
86
|
+
- More boilerplate than native SwiftUI approach
|
|
87
|
+
- MVVM conflicts with SwiftData's @Query requirements
|
|
88
|
+
- Can lead to massive ViewModels if not disciplined
|
|
89
|
+
- Requires iOS 17+ for @Observable benefits
|
|
90
|
+
|
|
91
|
+
**Current status:** Actively used, transitioning from ObservableObject to @Observable
|
|
92
|
+
|
|
93
|
+
**Learning curve:** Medium - familiar to many developers
|
|
94
|
+
|
|
95
|
+
```swift
|
|
96
|
+
// Model
|
|
97
|
+
struct User: Identifiable, Codable {
|
|
98
|
+
let id: UUID
|
|
99
|
+
let name: String
|
|
100
|
+
let email: String
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ViewModel using @Observable (iOS 17+)
|
|
104
|
+
import Observation
|
|
105
|
+
|
|
106
|
+
@Observable
|
|
107
|
+
@MainActor
|
|
108
|
+
class UserListViewModel {
|
|
109
|
+
var users: [User] = []
|
|
110
|
+
var isLoading = false
|
|
111
|
+
var errorMessage: String?
|
|
112
|
+
|
|
113
|
+
private let userService: UserServiceProtocol
|
|
114
|
+
|
|
115
|
+
init(userService: UserServiceProtocol = UserService()) {
|
|
116
|
+
self.userService = userService
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func loadUsers() async {
|
|
120
|
+
isLoading = true
|
|
121
|
+
errorMessage = nil
|
|
122
|
+
|
|
123
|
+
do {
|
|
124
|
+
users = try await userService.fetchUsers()
|
|
125
|
+
} catch {
|
|
126
|
+
errorMessage = error.localizedDescription
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
isLoading = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func deleteUser(_ user: User) async {
|
|
133
|
+
do {
|
|
134
|
+
try await userService.deleteUser(user.id)
|
|
135
|
+
users.removeAll { $0.id == user.id }
|
|
136
|
+
} catch {
|
|
137
|
+
errorMessage = "Failed to delete user"
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// View
|
|
143
|
+
struct UserListView: View {
|
|
144
|
+
@State private var viewModel = UserListViewModel()
|
|
145
|
+
|
|
146
|
+
var body: some View {
|
|
147
|
+
List {
|
|
148
|
+
ForEach(viewModel.users) { user in
|
|
149
|
+
UserRowView(user: user)
|
|
150
|
+
}
|
|
151
|
+
.onDelete { indexSet in
|
|
152
|
+
Task {
|
|
153
|
+
for index in indexSet {
|
|
154
|
+
await viewModel.deleteUser(viewModel.users[index])
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
.overlay {
|
|
160
|
+
if viewModel.isLoading {
|
|
161
|
+
ProgressView()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
|
165
|
+
Button("OK") { viewModel.errorMessage = nil }
|
|
166
|
+
} message: {
|
|
167
|
+
Text(viewModel.errorMessage ?? "")
|
|
168
|
+
}
|
|
169
|
+
.task {
|
|
170
|
+
await viewModel.loadUsers()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Service protocol for dependency injection
|
|
176
|
+
protocol UserServiceProtocol {
|
|
177
|
+
func fetchUsers() async throws -> [User]
|
|
178
|
+
func deleteUser(_ id: UUID) async throws
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
class UserService: UserServiceProtocol {
|
|
182
|
+
func fetchUsers() async throws -> [User] {
|
|
183
|
+
let url = URL(string: "https://api.example.com/users")!
|
|
184
|
+
let (data, _) = try await URLSession.shared.data(from: url)
|
|
185
|
+
return try JSONDecoder().decode([User].self, from: data)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func deleteUser(_ id: UUID) async throws {
|
|
189
|
+
let url = URL(string: "https://api.example.com/users/\(id)")!
|
|
190
|
+
var request = URLRequest(url: url)
|
|
191
|
+
request.httpMethod = "DELETE"
|
|
192
|
+
_ = try await URLSession.shared.data(for: request)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
</option>
|
|
197
|
+
|
|
198
|
+
<option name="The Composable Architecture (TCA)">
|
|
199
|
+
**When to use:** Complex apps with heavy state management, apps requiring time-travel debugging, teams prioritizing testability, apps with complex navigation flows
|
|
200
|
+
|
|
201
|
+
**Strengths:**
|
|
202
|
+
- Unidirectional data flow makes state changes predictable
|
|
203
|
+
- Exceptional testability - reducer tests are pure functions
|
|
204
|
+
- Built-in support for effects and dependencies
|
|
205
|
+
- Time-travel debugging capabilities
|
|
206
|
+
- Strong composition story for large features
|
|
207
|
+
- Stack-based and tree-based navigation support
|
|
208
|
+
- @Shared macro for state sharing across features
|
|
209
|
+
|
|
210
|
+
**Weaknesses:**
|
|
211
|
+
- Steep learning curve - requires learning reducers, stores, actions
|
|
212
|
+
- More boilerplate than MVVM or native approaches
|
|
213
|
+
- Can feel over-engineered for simple features
|
|
214
|
+
- External dependency (not Apple-provided)
|
|
215
|
+
- Scaling issues reported in very large multi-team apps
|
|
216
|
+
|
|
217
|
+
**Current status:** Version 1.13+ (actively maintained by Point-Free, 2024)
|
|
218
|
+
|
|
219
|
+
**Learning curve:** Hard - Redux concepts unfamiliar to many iOS developers
|
|
220
|
+
|
|
221
|
+
```swift
|
|
222
|
+
import ComposableArchitecture
|
|
223
|
+
|
|
224
|
+
// Feature definition
|
|
225
|
+
@Reducer
|
|
226
|
+
struct UserList {
|
|
227
|
+
@ObservableState
|
|
228
|
+
struct State {
|
|
229
|
+
var users: [User] = []
|
|
230
|
+
var isLoading = false
|
|
231
|
+
var errorMessage: String?
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
enum Action {
|
|
235
|
+
case loadUsers
|
|
236
|
+
case usersResponse(Result<[User], Error>)
|
|
237
|
+
case deleteUser(User)
|
|
238
|
+
case deleteUserResponse(Result<Void, Error>)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
@Dependency(\.userClient) var userClient
|
|
242
|
+
|
|
243
|
+
var body: some Reducer<State, Action> {
|
|
244
|
+
Reduce { state, action in
|
|
245
|
+
switch action {
|
|
246
|
+
case .loadUsers:
|
|
247
|
+
state.isLoading = true
|
|
248
|
+
state.errorMessage = nil
|
|
249
|
+
return .run { send in
|
|
250
|
+
await send(.usersResponse(
|
|
251
|
+
Result { try await userClient.fetchUsers() }
|
|
252
|
+
))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case let .usersResponse(.success(users)):
|
|
256
|
+
state.isLoading = false
|
|
257
|
+
state.users = users
|
|
258
|
+
return .none
|
|
259
|
+
|
|
260
|
+
case let .usersResponse(.failure(error)):
|
|
261
|
+
state.isLoading = false
|
|
262
|
+
state.errorMessage = error.localizedDescription
|
|
263
|
+
return .none
|
|
264
|
+
|
|
265
|
+
case let .deleteUser(user):
|
|
266
|
+
return .run { send in
|
|
267
|
+
await send(.deleteUserResponse(
|
|
268
|
+
Result { try await userClient.deleteUser(user.id) }
|
|
269
|
+
))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case .deleteUserResponse(.success):
|
|
273
|
+
return .none
|
|
274
|
+
|
|
275
|
+
case let .deleteUserResponse(.failure(error)):
|
|
276
|
+
state.errorMessage = "Failed to delete user"
|
|
277
|
+
return .none
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// View
|
|
284
|
+
struct UserListView: View {
|
|
285
|
+
let store: StoreOf<UserList>
|
|
286
|
+
|
|
287
|
+
var body: some View {
|
|
288
|
+
List {
|
|
289
|
+
ForEach(store.users) { user in
|
|
290
|
+
UserRowView(user: user)
|
|
291
|
+
}
|
|
292
|
+
.onDelete { indexSet in
|
|
293
|
+
for index in indexSet {
|
|
294
|
+
store.send(.deleteUser(store.users[index]))
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
.overlay {
|
|
299
|
+
if store.isLoading {
|
|
300
|
+
ProgressView()
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
.alert(
|
|
304
|
+
"Error",
|
|
305
|
+
isPresented: .constant(store.errorMessage != nil)
|
|
306
|
+
) {
|
|
307
|
+
Button("OK") { }
|
|
308
|
+
} message: {
|
|
309
|
+
Text(store.errorMessage ?? "")
|
|
310
|
+
}
|
|
311
|
+
.task {
|
|
312
|
+
store.send(.loadUsers)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Dependency client
|
|
318
|
+
struct UserClient {
|
|
319
|
+
var fetchUsers: () async throws -> [User]
|
|
320
|
+
var deleteUser: (UUID) async throws -> Void
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
extension UserClient: DependencyKey {
|
|
324
|
+
static let liveValue = UserClient(
|
|
325
|
+
fetchUsers: {
|
|
326
|
+
let url = URL(string: "https://api.example.com/users")!
|
|
327
|
+
let (data, _) = try await URLSession.shared.data(from: url)
|
|
328
|
+
return try JSONDecoder().decode([User].self, from: data)
|
|
329
|
+
},
|
|
330
|
+
deleteUser: { id in
|
|
331
|
+
let url = URL(string: "https://api.example.com/users/\(id)")!
|
|
332
|
+
var request = URLRequest(url: url)
|
|
333
|
+
request.httpMethod = "DELETE"
|
|
334
|
+
_ = try await URLSession.shared.data(for: request)
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
extension DependencyValues {
|
|
340
|
+
var userClient: UserClient {
|
|
341
|
+
get { self[UserClient.self] }
|
|
342
|
+
set { self[UserClient.self] = newValue }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
</option>
|
|
347
|
+
|
|
348
|
+
<option name="Clean Architecture with Feature Modules">
|
|
349
|
+
**When to use:** Large apps with multiple teams, apps expecting significant future growth, apps requiring strong architectural boundaries, enterprise applications
|
|
350
|
+
|
|
351
|
+
**Strengths:**
|
|
352
|
+
- Enforces clear separation of layers (Presentation, Domain, Data)
|
|
353
|
+
- Modules can be developed and tested independently
|
|
354
|
+
- Faster build times (only changed modules rebuild)
|
|
355
|
+
- Strong architectural boundaries prevent tangled dependencies
|
|
356
|
+
- Works well with Swift Package Manager
|
|
357
|
+
- Teams can work on features autonomously
|
|
358
|
+
- Easy to remove entire features (just delete the package)
|
|
359
|
+
|
|
360
|
+
**Weaknesses:**
|
|
361
|
+
- Significant upfront architectural investment
|
|
362
|
+
- Can be over-engineering for small teams or apps
|
|
363
|
+
- Requires discipline to maintain boundaries
|
|
364
|
+
- More complex dependency management
|
|
365
|
+
|
|
366
|
+
**Current status:** Industry best practice for large-scale apps (2024)
|
|
367
|
+
|
|
368
|
+
**Learning curve:** Hard - requires understanding of layers, boundaries, and modularization
|
|
369
|
+
|
|
370
|
+
```swift
|
|
371
|
+
// Domain Layer (Core business logic - no framework dependencies)
|
|
372
|
+
// Package: Domain
|
|
373
|
+
|
|
374
|
+
struct User: Identifiable {
|
|
375
|
+
let id: UUID
|
|
376
|
+
let name: String
|
|
377
|
+
let email: String
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
protocol UserRepositoryProtocol {
|
|
381
|
+
func fetchUsers() async throws -> [User]
|
|
382
|
+
func deleteUser(_ id: UUID) async throws
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Data Layer (Infrastructure - API, database, etc.)
|
|
386
|
+
// Package: Data
|
|
387
|
+
|
|
388
|
+
import Foundation
|
|
389
|
+
|
|
390
|
+
public class UserRepository: UserRepositoryProtocol {
|
|
391
|
+
private let apiClient: APIClient
|
|
392
|
+
|
|
393
|
+
public init(apiClient: APIClient) {
|
|
394
|
+
self.apiClient = apiClient
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
public func fetchUsers() async throws -> [User] {
|
|
398
|
+
let dtos: [UserDTO] = try await apiClient.get("/users")
|
|
399
|
+
return dtos.map { $0.toDomain() }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
public func deleteUser(_ id: UUID) async throws {
|
|
403
|
+
try await apiClient.delete("/users/\(id)")
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
struct UserDTO: Codable {
|
|
408
|
+
let id: UUID
|
|
409
|
+
let name: String
|
|
410
|
+
let email: String
|
|
411
|
+
|
|
412
|
+
func toDomain() -> User {
|
|
413
|
+
User(id: id, name: name, email: email)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Presentation Layer (Feature module)
|
|
418
|
+
// Package: Features/UserList
|
|
419
|
+
|
|
420
|
+
import SwiftUI
|
|
421
|
+
import Observation
|
|
422
|
+
import Domain
|
|
423
|
+
|
|
424
|
+
@Observable
|
|
425
|
+
@MainActor
|
|
426
|
+
public class UserListViewModel {
|
|
427
|
+
var users: [User] = []
|
|
428
|
+
var isLoading = false
|
|
429
|
+
var errorMessage: String?
|
|
430
|
+
|
|
431
|
+
private let userRepository: UserRepositoryProtocol
|
|
432
|
+
|
|
433
|
+
public init(userRepository: UserRepositoryProtocol) {
|
|
434
|
+
self.userRepository = userRepository
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
func loadUsers() async {
|
|
438
|
+
isLoading = true
|
|
439
|
+
do {
|
|
440
|
+
users = try await userRepository.fetchUsers()
|
|
441
|
+
} catch {
|
|
442
|
+
errorMessage = error.localizedDescription
|
|
443
|
+
}
|
|
444
|
+
isLoading = false
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
public struct UserListView: View {
|
|
449
|
+
@State private var viewModel: UserListViewModel
|
|
450
|
+
|
|
451
|
+
public init(viewModel: UserListViewModel) {
|
|
452
|
+
self.viewModel = viewModel
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
public var body: some View {
|
|
456
|
+
List(viewModel.users) { user in
|
|
457
|
+
Text(user.name)
|
|
458
|
+
}
|
|
459
|
+
.task {
|
|
460
|
+
await viewModel.loadUsers()
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// App Layer (Composition root)
|
|
466
|
+
// Package: App
|
|
467
|
+
|
|
468
|
+
import SwiftUI
|
|
469
|
+
|
|
470
|
+
@main
|
|
471
|
+
struct MyApp: App {
|
|
472
|
+
// Dependency container
|
|
473
|
+
private let container = DependencyContainer()
|
|
474
|
+
|
|
475
|
+
var body: some Scene {
|
|
476
|
+
WindowGroup {
|
|
477
|
+
UserListView(
|
|
478
|
+
viewModel: container.makeUserListViewModel()
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
class DependencyContainer {
|
|
485
|
+
private lazy var apiClient = APIClient(baseURL: "https://api.example.com")
|
|
486
|
+
|
|
487
|
+
private lazy var userRepository: UserRepositoryProtocol = {
|
|
488
|
+
UserRepository(apiClient: apiClient)
|
|
489
|
+
}()
|
|
490
|
+
|
|
491
|
+
func makeUserListViewModel() -> UserListViewModel {
|
|
492
|
+
UserListViewModel(userRepository: userRepository)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
</option>
|
|
497
|
+
</options>
|
|
498
|
+
|
|
499
|
+
<decision_tree>
|
|
500
|
+
## Choosing the Right Approach
|
|
501
|
+
|
|
502
|
+
**If building a simple app (under 10 screens, minimal business logic):** Use SwiftUI Native because architectural overhead isn't justified and SwiftUI's built-in patterns are sufficient.
|
|
503
|
+
|
|
504
|
+
**If using SwiftData extensively:** Use SwiftUI Native or consider Clean Architecture. Avoid MVVM because @Query requires views to manage data directly, conflicting with ViewModel patterns.
|
|
505
|
+
|
|
506
|
+
**If you need testability and moderate complexity (10-30 screens):** Use MVVM with @Observable because it provides clean separation, is industry-standard, and offers excellent testability with minimal overhead.
|
|
507
|
+
|
|
508
|
+
**If you have complex state management, navigation, or side effects:** Consider The Composable Architecture because its unidirectional data flow and built-in effect handling excel at managing complexity.
|
|
509
|
+
|
|
510
|
+
**If building a large app with multiple teams:** Use Clean Architecture with Feature Modules because it enforces boundaries, enables parallel development, and improves build times through modularization.
|
|
511
|
+
|
|
512
|
+
**If team is unfamiliar with iOS architectures:** Start with MVVM because it's the most widely understood pattern and has abundant learning resources.
|
|
513
|
+
|
|
514
|
+
**If prototyping or validating product-market fit:** Use SwiftUI Native because you can ship fastest and refactor to MVVM or Clean Architecture later when requirements stabilize.
|
|
515
|
+
|
|
516
|
+
**Default recommendation:** MVVM with @Observable for most production apps because it balances simplicity, testability, and scalability. Migrate to Clean Architecture with modules only when team size or app complexity demands it.
|
|
517
|
+
|
|
518
|
+
**Avoid MVVM when:** Using SwiftData heavily, or building very simple apps where the architectural overhead slows development without providing value.
|
|
519
|
+
|
|
520
|
+
**Avoid TCA when:** Team lacks Redux experience, building simple CRUD apps, or working in large multi-team environments where TCA's scaling limitations may surface.
|
|
521
|
+
</decision_tree>
|
|
522
|
+
|
|
523
|
+
<patterns>
|
|
524
|
+
## Common Patterns
|
|
525
|
+
|
|
526
|
+
<pattern name="Dependency Injection with Factory">
|
|
527
|
+
**Use when:** Need compile-time safe dependency injection without manual container setup
|
|
528
|
+
|
|
529
|
+
Factory is the current recommended DI library for Swift (2024). Import the library as "FactoryKit" to avoid naming conflicts.
|
|
530
|
+
|
|
531
|
+
**Implementation:**
|
|
532
|
+
```swift
|
|
533
|
+
// 1. Install Factory via SPM
|
|
534
|
+
// https://github.com/hmlongco/Factory
|
|
535
|
+
// Add package, select "FactoryKit" library
|
|
536
|
+
|
|
537
|
+
// 2. Define container with factories
|
|
538
|
+
import FactoryKit
|
|
539
|
+
|
|
540
|
+
extension Container {
|
|
541
|
+
var apiClient: Factory<APIClient> {
|
|
542
|
+
Factory(self) { APIClient(baseURL: "https://api.example.com") }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
var userRepository: Factory<UserRepositoryProtocol> {
|
|
546
|
+
Factory(self) { UserRepository(apiClient: self.apiClient()) }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
var userListViewModel: Factory<UserListViewModel> {
|
|
550
|
+
Factory(self) {
|
|
551
|
+
UserListViewModel(userRepository: self.userRepository())
|
|
552
|
+
}
|
|
553
|
+
.scope(.shared) // Singleton if needed
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 3. Inject in ViewModels using @Injected
|
|
558
|
+
@Observable
|
|
559
|
+
@MainActor
|
|
560
|
+
class UserListViewModel {
|
|
561
|
+
@ObservationIgnored @Injected(\.userRepository)
|
|
562
|
+
private var userRepository: UserRepositoryProtocol
|
|
563
|
+
|
|
564
|
+
var users: [User] = []
|
|
565
|
+
|
|
566
|
+
func loadUsers() async {
|
|
567
|
+
users = try await userRepository.fetchUsers()
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 4. Inject in Views using @Injected
|
|
572
|
+
struct UserListView: View {
|
|
573
|
+
@State private var viewModel = Container.shared.userListViewModel()
|
|
574
|
+
|
|
575
|
+
var body: some View {
|
|
576
|
+
List(viewModel.users) { user in
|
|
577
|
+
Text(user.name)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 5. Override for testing
|
|
583
|
+
extension Container {
|
|
584
|
+
var mockUserRepository: Factory<UserRepositoryProtocol> {
|
|
585
|
+
Factory(self) { MockUserRepository() }
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**Considerations:**
|
|
591
|
+
- Use @ObservationIgnored for @Injected properties inside @Observable classes
|
|
592
|
+
- Factory 2.5+ supports Swift 6 strict concurrency
|
|
593
|
+
- Scopes: .singleton, .shared, .cached, .graph, .unique
|
|
594
|
+
- Register mock factories in test targets for easy testing
|
|
595
|
+
</pattern>
|
|
596
|
+
|
|
597
|
+
<pattern name="Environment-Based Dependency Injection">
|
|
598
|
+
**Use when:** Need SwiftUI-native dependency injection without external libraries
|
|
599
|
+
|
|
600
|
+
**Implementation:**
|
|
601
|
+
```swift
|
|
602
|
+
// 1. Define dependency key
|
|
603
|
+
struct UserRepositoryKey: EnvironmentKey {
|
|
604
|
+
static let defaultValue: UserRepositoryProtocol = UserRepository()
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
extension EnvironmentValues {
|
|
608
|
+
var userRepository: UserRepositoryProtocol {
|
|
609
|
+
get { self[UserRepositoryKey.self] }
|
|
610
|
+
set { self[UserRepositoryKey.self] = newValue }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// 2. Provide dependency at app root
|
|
615
|
+
@main
|
|
616
|
+
struct MyApp: App {
|
|
617
|
+
var body: some Scene {
|
|
618
|
+
WindowGroup {
|
|
619
|
+
ContentView()
|
|
620
|
+
.environment(\.userRepository, UserRepository())
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// 3. Inject in ViewModels
|
|
626
|
+
@Observable
|
|
627
|
+
@MainActor
|
|
628
|
+
class UserListViewModel {
|
|
629
|
+
var users: [User] = []
|
|
630
|
+
private let userRepository: UserRepositoryProtocol
|
|
631
|
+
|
|
632
|
+
init(userRepository: UserRepositoryProtocol) {
|
|
633
|
+
self.userRepository = userRepository
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
func loadUsers() async {
|
|
637
|
+
users = try await userRepository.fetchUsers()
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// 4. Access in Views
|
|
642
|
+
struct UserListView: View {
|
|
643
|
+
@Environment(\.userRepository) private var userRepository
|
|
644
|
+
@State private var viewModel: UserListViewModel
|
|
645
|
+
|
|
646
|
+
init() {
|
|
647
|
+
// Can't access @Environment in init - use onAppear workaround
|
|
648
|
+
_viewModel = State(initialValue: UserListViewModel(
|
|
649
|
+
userRepository: UserRepository() // temporary
|
|
650
|
+
))
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
var body: some View {
|
|
654
|
+
List(viewModel.users) { user in
|
|
655
|
+
Text(user.name)
|
|
656
|
+
}
|
|
657
|
+
.onAppear {
|
|
658
|
+
// Replace with environment-injected dependency
|
|
659
|
+
viewModel = UserListViewModel(userRepository: userRepository)
|
|
660
|
+
Task { await viewModel.loadUsers() }
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
**Considerations:**
|
|
667
|
+
- SwiftUI-native approach (no external dependencies)
|
|
668
|
+
- Can't access @Environment in init - requires workaround
|
|
669
|
+
- Better for simple apps; Factory scales better for complex DI
|
|
670
|
+
</pattern>
|
|
671
|
+
|
|
672
|
+
<pattern name="Repository Pattern for Data Access">
|
|
673
|
+
**Use when:** Abstracting data sources (API, database, cache) from business logic
|
|
674
|
+
|
|
675
|
+
**Implementation:**
|
|
676
|
+
```swift
|
|
677
|
+
// Protocol in Domain layer
|
|
678
|
+
protocol UserRepositoryProtocol {
|
|
679
|
+
func fetchUsers() async throws -> [User]
|
|
680
|
+
func getUser(id: UUID) async throws -> User
|
|
681
|
+
func saveUser(_ user: User) async throws
|
|
682
|
+
func deleteUser(id: UUID) async throws
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Implementation in Data layer
|
|
686
|
+
class UserRepository: UserRepositoryProtocol {
|
|
687
|
+
private let apiClient: APIClient
|
|
688
|
+
private let cache: CacheService
|
|
689
|
+
|
|
690
|
+
init(apiClient: APIClient, cache: CacheService) {
|
|
691
|
+
self.apiClient = apiClient
|
|
692
|
+
self.cache = cache
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
func fetchUsers() async throws -> [User] {
|
|
696
|
+
// Check cache first
|
|
697
|
+
if let cached: [User] = cache.get(key: "users") {
|
|
698
|
+
return cached
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Fetch from API
|
|
702
|
+
let dtos: [UserDTO] = try await apiClient.get("/users")
|
|
703
|
+
let users = dtos.map { $0.toDomain() }
|
|
704
|
+
|
|
705
|
+
// Update cache
|
|
706
|
+
cache.set(key: "users", value: users)
|
|
707
|
+
|
|
708
|
+
return users
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
func getUser(id: UUID) async throws -> User {
|
|
712
|
+
let dto: UserDTO = try await apiClient.get("/users/\(id)")
|
|
713
|
+
return dto.toDomain()
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
func saveUser(_ user: User) async throws {
|
|
717
|
+
let dto = UserDTO(from: user)
|
|
718
|
+
try await apiClient.post("/users", body: dto)
|
|
719
|
+
|
|
720
|
+
// Invalidate cache
|
|
721
|
+
cache.remove(key: "users")
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
func deleteUser(id: UUID) async throws {
|
|
725
|
+
try await apiClient.delete("/users/\(id)")
|
|
726
|
+
cache.remove(key: "users")
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// DTO for API mapping
|
|
731
|
+
struct UserDTO: Codable {
|
|
732
|
+
let id: UUID
|
|
733
|
+
let name: String
|
|
734
|
+
let email: String
|
|
735
|
+
|
|
736
|
+
func toDomain() -> User {
|
|
737
|
+
User(id: id, name: name, email: email)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
init(from user: User) {
|
|
741
|
+
self.id = user.id
|
|
742
|
+
self.name = user.name
|
|
743
|
+
self.email = user.email
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
**Considerations:**
|
|
749
|
+
- Repository owns caching strategy
|
|
750
|
+
- DTOs map between API and domain models
|
|
751
|
+
- Protocol enables easy mocking for tests
|
|
752
|
+
- Keeps networking details out of ViewModels
|
|
753
|
+
</pattern>
|
|
754
|
+
|
|
755
|
+
<pattern name="Feature Flag Service">
|
|
756
|
+
**Use when:** Need to toggle features without app updates
|
|
757
|
+
|
|
758
|
+
**Implementation:**
|
|
759
|
+
```swift
|
|
760
|
+
// Feature flag service
|
|
761
|
+
@Observable
|
|
762
|
+
@MainActor
|
|
763
|
+
class FeatureFlagService {
|
|
764
|
+
private(set) var flags: [String: Bool] = [:]
|
|
765
|
+
|
|
766
|
+
func isEnabled(_ feature: FeatureFlag) -> Bool {
|
|
767
|
+
flags[feature.rawValue] ?? feature.defaultValue
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
func enable(_ feature: FeatureFlag) {
|
|
771
|
+
flags[feature.rawValue] = true
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
func disable(_ feature: FeatureFlag) {
|
|
775
|
+
flags[feature.rawValue] = false
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
func loadRemoteFlags() async {
|
|
779
|
+
// Fetch from remote config service
|
|
780
|
+
// Update flags dictionary
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
enum FeatureFlag: String {
|
|
785
|
+
case newUserProfile = "new_user_profile"
|
|
786
|
+
case darkModeToggle = "dark_mode_toggle"
|
|
787
|
+
case experimentalSearch = "experimental_search"
|
|
788
|
+
|
|
789
|
+
var defaultValue: Bool {
|
|
790
|
+
switch self {
|
|
791
|
+
case .newUserProfile: return false
|
|
792
|
+
case .darkModeToggle: return true
|
|
793
|
+
case .experimentalSearch: return false
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Use in views
|
|
799
|
+
struct ContentView: View {
|
|
800
|
+
@Environment(\.featureFlags) private var featureFlags
|
|
801
|
+
|
|
802
|
+
var body: some View {
|
|
803
|
+
VStack {
|
|
804
|
+
if featureFlags.isEnabled(.newUserProfile) {
|
|
805
|
+
NewUserProfileView()
|
|
806
|
+
} else {
|
|
807
|
+
LegacyUserProfileView()
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Environment setup
|
|
814
|
+
struct FeatureFlagKey: EnvironmentKey {
|
|
815
|
+
static let defaultValue = FeatureFlagService()
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
extension EnvironmentValues {
|
|
819
|
+
var featureFlags: FeatureFlagService {
|
|
820
|
+
get { self[FeatureFlagKey.self] }
|
|
821
|
+
set { self[FeatureFlagKey.self] = newValue }
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
**Considerations:**
|
|
827
|
+
- Load remote flags at app startup
|
|
828
|
+
- Use @MainActor for thread-safe access
|
|
829
|
+
- Feature flags enable A/B testing and gradual rollouts
|
|
830
|
+
</pattern>
|
|
831
|
+
|
|
832
|
+
<pattern name="Coordinator Pattern for Navigation">
|
|
833
|
+
**Use when:** Need centralized navigation control separate from views
|
|
834
|
+
|
|
835
|
+
**Implementation:**
|
|
836
|
+
```swift
|
|
837
|
+
// Navigation coordinator
|
|
838
|
+
@Observable
|
|
839
|
+
@MainActor
|
|
840
|
+
class AppCoordinator {
|
|
841
|
+
var navigationPath = NavigationPath()
|
|
842
|
+
var presentedSheet: SheetDestination?
|
|
843
|
+
|
|
844
|
+
func push(_ destination: Destination) {
|
|
845
|
+
navigationPath.append(destination)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
func pop() {
|
|
849
|
+
navigationPath.removeLast()
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
func popToRoot() {
|
|
853
|
+
navigationPath = NavigationPath()
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
func present(_ sheet: SheetDestination) {
|
|
857
|
+
presentedSheet = sheet
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
func dismiss() {
|
|
861
|
+
presentedSheet = nil
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
enum Destination: Hashable {
|
|
866
|
+
case userDetail(User)
|
|
867
|
+
case settings
|
|
868
|
+
case editProfile
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
enum SheetDestination: Identifiable {
|
|
872
|
+
case addUser
|
|
873
|
+
case filter
|
|
874
|
+
|
|
875
|
+
var id: String {
|
|
876
|
+
switch self {
|
|
877
|
+
case .addUser: return "addUser"
|
|
878
|
+
case .filter: return "filter"
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Root view with coordinator
|
|
884
|
+
struct RootView: View {
|
|
885
|
+
@State private var coordinator = AppCoordinator()
|
|
886
|
+
|
|
887
|
+
var body: some View {
|
|
888
|
+
NavigationStack(path: $coordinator.navigationPath) {
|
|
889
|
+
UserListView(coordinator: coordinator)
|
|
890
|
+
.navigationDestination(for: Destination.self) { destination in
|
|
891
|
+
switch destination {
|
|
892
|
+
case .userDetail(let user):
|
|
893
|
+
UserDetailView(user: user, coordinator: coordinator)
|
|
894
|
+
case .settings:
|
|
895
|
+
SettingsView(coordinator: coordinator)
|
|
896
|
+
case .editProfile:
|
|
897
|
+
EditProfileView(coordinator: coordinator)
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
.sheet(item: $coordinator.presentedSheet) { sheet in
|
|
902
|
+
switch sheet {
|
|
903
|
+
case .addUser:
|
|
904
|
+
AddUserView(coordinator: coordinator)
|
|
905
|
+
case .filter:
|
|
906
|
+
FilterView(coordinator: coordinator)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Views use coordinator for navigation
|
|
913
|
+
struct UserListView: View {
|
|
914
|
+
let coordinator: AppCoordinator
|
|
915
|
+
|
|
916
|
+
var body: some View {
|
|
917
|
+
List {
|
|
918
|
+
ForEach(users) { user in
|
|
919
|
+
Button(user.name) {
|
|
920
|
+
coordinator.push(.userDetail(user))
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
.toolbar {
|
|
925
|
+
Button("Add") {
|
|
926
|
+
coordinator.present(.addUser)
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
**Considerations:**
|
|
934
|
+
- Coordinator owns all navigation state
|
|
935
|
+
- Testable - can verify navigation logic independently
|
|
936
|
+
- Works well with deep linking
|
|
937
|
+
- See navigation.md for more patterns
|
|
938
|
+
</pattern>
|
|
939
|
+
</patterns>
|
|
940
|
+
|
|
941
|
+
<anti_patterns>
|
|
942
|
+
## What NOT to Do
|
|
943
|
+
|
|
944
|
+
<anti_pattern name="Massive ViewModels">
|
|
945
|
+
**Problem:** Putting all feature logic into a single ViewModel class
|
|
946
|
+
|
|
947
|
+
```swift
|
|
948
|
+
// DON'T: Massive ViewModel with 1000+ lines
|
|
949
|
+
@Observable
|
|
950
|
+
@MainActor
|
|
951
|
+
class UserViewModel {
|
|
952
|
+
// User list logic
|
|
953
|
+
var users: [User] = []
|
|
954
|
+
func loadUsers() async { }
|
|
955
|
+
func deleteUser() { }
|
|
956
|
+
|
|
957
|
+
// User detail logic
|
|
958
|
+
var selectedUser: User?
|
|
959
|
+
func loadUserDetails() { }
|
|
960
|
+
|
|
961
|
+
// Settings logic
|
|
962
|
+
var notificationsEnabled = false
|
|
963
|
+
func saveSettings() { }
|
|
964
|
+
|
|
965
|
+
// Profile editing logic
|
|
966
|
+
var editedName = ""
|
|
967
|
+
var editedEmail = ""
|
|
968
|
+
func updateProfile() { }
|
|
969
|
+
|
|
970
|
+
// Search logic
|
|
971
|
+
var searchQuery = ""
|
|
972
|
+
var searchResults: [User] = []
|
|
973
|
+
func search() { }
|
|
974
|
+
|
|
975
|
+
// ... 900 more lines
|
|
976
|
+
}
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
**Why it's bad:**
|
|
980
|
+
- Hard to test (must mock entireViewModel for one feature)
|
|
981
|
+
- Poor cohesion (unrelated concerns mixed together)
|
|
982
|
+
- Difficult to navigate and understand
|
|
983
|
+
- High merge conflict risk in teams
|
|
984
|
+
|
|
985
|
+
**Instead:** Create feature-specific ViewModels
|
|
986
|
+
|
|
987
|
+
```swift
|
|
988
|
+
// DO: Separate ViewModels per feature
|
|
989
|
+
@Observable
|
|
990
|
+
@MainActor
|
|
991
|
+
class UserListViewModel {
|
|
992
|
+
var users: [User] = []
|
|
993
|
+
var isLoading = false
|
|
994
|
+
|
|
995
|
+
func loadUsers() async { }
|
|
996
|
+
func deleteUser(_ user: User) { }
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
@Observable
|
|
1000
|
+
@MainActor
|
|
1001
|
+
class UserDetailViewModel {
|
|
1002
|
+
var user: User
|
|
1003
|
+
var isEditing = false
|
|
1004
|
+
|
|
1005
|
+
init(user: User) {
|
|
1006
|
+
self.user = user
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
func loadDetails() async { }
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
@Observable
|
|
1013
|
+
@MainActor
|
|
1014
|
+
class UserSearchViewModel {
|
|
1015
|
+
var query = ""
|
|
1016
|
+
var results: [User] = []
|
|
1017
|
+
|
|
1018
|
+
func search() async { }
|
|
1019
|
+
}
|
|
1020
|
+
```
|
|
1021
|
+
</anti_pattern>
|
|
1022
|
+
|
|
1023
|
+
<anti_pattern name="Using ObservableObject Instead of @Observable">
|
|
1024
|
+
**Problem:** Still using the old ObservableObject protocol with @Published
|
|
1025
|
+
|
|
1026
|
+
```swift
|
|
1027
|
+
// DON'T: Old ObservableObject pattern (pre-iOS 17)
|
|
1028
|
+
class UserViewModel: ObservableObject {
|
|
1029
|
+
@Published var users: [User] = []
|
|
1030
|
+
@Published var isLoading = false
|
|
1031
|
+
@Published var errorMessage: String?
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
struct UserListView: View {
|
|
1035
|
+
@StateObject private var viewModel = UserViewModel()
|
|
1036
|
+
// ...
|
|
1037
|
+
}
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
**Why it's bad:**
|
|
1041
|
+
- Worse performance (all @Published changes trigger view updates)
|
|
1042
|
+
- More boilerplate (@Published everywhere)
|
|
1043
|
+
- Forces use of @StateObject instead of @State
|
|
1044
|
+
- Triggers unnecessary view redraws
|
|
1045
|
+
|
|
1046
|
+
**Instead:** Use @Observable macro (iOS 17+)
|
|
1047
|
+
|
|
1048
|
+
```swift
|
|
1049
|
+
// DO: Modern @Observable pattern
|
|
1050
|
+
import Observation
|
|
1051
|
+
|
|
1052
|
+
@Observable
|
|
1053
|
+
@MainActor
|
|
1054
|
+
class UserViewModel {
|
|
1055
|
+
var users: [User] = []
|
|
1056
|
+
var isLoading = false
|
|
1057
|
+
var errorMessage: String?
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
struct UserListView: View {
|
|
1061
|
+
@State private var viewModel = UserViewModel()
|
|
1062
|
+
|
|
1063
|
+
var body: some View {
|
|
1064
|
+
// Only redraws when properties accessed in body change
|
|
1065
|
+
List(viewModel.users) { user in
|
|
1066
|
+
Text(user.name)
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
**Migration note:** If supporting iOS 16 or earlier, ObservableObject is still required. Use @Observable for iOS 17+ only projects.
|
|
1073
|
+
</anti_pattern>
|
|
1074
|
+
|
|
1075
|
+
<anti_pattern name="Mixing SwiftData @Query with MVVM">
|
|
1076
|
+
**Problem:** Trying to use @Query inside a ViewModel
|
|
1077
|
+
|
|
1078
|
+
```swift
|
|
1079
|
+
// DON'T: @Query doesn't work in ViewModels
|
|
1080
|
+
@Observable
|
|
1081
|
+
@MainActor
|
|
1082
|
+
class UserViewModel {
|
|
1083
|
+
@Query var users: [User] // ERROR: @Query only works in Views
|
|
1084
|
+
}
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
**Why it's bad:**
|
|
1088
|
+
- @Query requires SwiftUI view context
|
|
1089
|
+
- Creates compile errors
|
|
1090
|
+
- Forces awkward workarounds
|
|
1091
|
+
|
|
1092
|
+
**Instead:** Use @Query directly in views or avoid MVVM with SwiftData
|
|
1093
|
+
|
|
1094
|
+
```swift
|
|
1095
|
+
// DO: Use @Query in views directly
|
|
1096
|
+
struct UserListView: View {
|
|
1097
|
+
@Query(sort: \User.name) private var users: [User]
|
|
1098
|
+
|
|
1099
|
+
var body: some View {
|
|
1100
|
+
List(users) { user in
|
|
1101
|
+
Text(user.name)
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// OR: Use ModelContext directly in ViewModel if you need MVVM
|
|
1107
|
+
@Observable
|
|
1108
|
+
@MainActor
|
|
1109
|
+
class UserViewModel {
|
|
1110
|
+
private let modelContext: ModelContext
|
|
1111
|
+
var users: [User] = []
|
|
1112
|
+
|
|
1113
|
+
init(modelContext: ModelContext) {
|
|
1114
|
+
self.modelContext = modelContext
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
func loadUsers() {
|
|
1118
|
+
let descriptor = FetchDescriptor<User>(
|
|
1119
|
+
sortBy: [SortDescriptor(\User.name)]
|
|
1120
|
+
)
|
|
1121
|
+
users = (try? modelContext.fetch(descriptor)) ?? []
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
**Best approach:** If using SwiftData heavily, prefer SwiftUI Native architecture over MVVM.
|
|
1127
|
+
</anti_pattern>
|
|
1128
|
+
|
|
1129
|
+
<anti_pattern name="Initializing @Observable with @State Incorrectly">
|
|
1130
|
+
**Problem:** Creating new ViewModel instance on every view redraw
|
|
1131
|
+
|
|
1132
|
+
```swift
|
|
1133
|
+
// DON'T: Creates new instance every redraw
|
|
1134
|
+
struct UserListView: View {
|
|
1135
|
+
@State private var viewModel = UserListViewModel() // Wrong!
|
|
1136
|
+
|
|
1137
|
+
var body: some View {
|
|
1138
|
+
List(viewModel.users) { user in
|
|
1139
|
+
Text(user.name)
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
**Why it seems wrong:** Looks like it creates new instance on every redraw
|
|
1146
|
+
|
|
1147
|
+
**Why it's actually correct:** @State caches the instance across redraws. The initializer only runs on first render.
|
|
1148
|
+
|
|
1149
|
+
**Key insight:** With @Observable and @State, the apparent anti-pattern is actually the correct pattern. Unlike @StateObject (which requires @escaping closure workaround), @State with @Observable caches the value automatically.
|
|
1150
|
+
|
|
1151
|
+
**Still avoid:**
|
|
1152
|
+
```swift
|
|
1153
|
+
// DON'T: Creating without @State
|
|
1154
|
+
struct UserListView: View {
|
|
1155
|
+
private let viewModel = UserListViewModel() // Wrong - recreated every time
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// DON'T: Using @StateObject with @Observable
|
|
1159
|
+
struct UserListView: View {
|
|
1160
|
+
@StateObject private var viewModel = UserListViewModel() // Wrong - use @State
|
|
1161
|
+
}
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
**Do:**
|
|
1165
|
+
```swift
|
|
1166
|
+
// DO: Use @State with @Observable
|
|
1167
|
+
struct UserListView: View {
|
|
1168
|
+
@State private var viewModel = UserListViewModel()
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// DO: Or inject from parent
|
|
1172
|
+
struct UserListView: View {
|
|
1173
|
+
let viewModel: UserListViewModel
|
|
1174
|
+
}
|
|
1175
|
+
```
|
|
1176
|
+
</anti_pattern>
|
|
1177
|
+
|
|
1178
|
+
<anti_pattern name="Passing @Environment to ViewModels in init">
|
|
1179
|
+
**Problem:** Trying to access @Environment values in view initializer
|
|
1180
|
+
|
|
1181
|
+
```swift
|
|
1182
|
+
// DON'T: Can't access @Environment in init
|
|
1183
|
+
struct UserListView: View {
|
|
1184
|
+
@Environment(\.userRepository) private var userRepository
|
|
1185
|
+
@State private var viewModel: UserListViewModel
|
|
1186
|
+
|
|
1187
|
+
init() {
|
|
1188
|
+
// ERROR: Can't access userRepository here
|
|
1189
|
+
_viewModel = State(initialValue: UserListViewModel(
|
|
1190
|
+
userRepository: userRepository
|
|
1191
|
+
))
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
**Why it's bad:**
|
|
1197
|
+
- @Environment not available until view is in the hierarchy
|
|
1198
|
+
- Causes compile errors or runtime crashes
|
|
1199
|
+
- Requires awkward workarounds
|
|
1200
|
+
|
|
1201
|
+
**Instead:** Use Factory for DI or pass dependencies explicitly
|
|
1202
|
+
|
|
1203
|
+
```swift
|
|
1204
|
+
// DO: Use Factory for clean DI
|
|
1205
|
+
import FactoryKit
|
|
1206
|
+
|
|
1207
|
+
extension Container {
|
|
1208
|
+
var userRepository: Factory<UserRepositoryProtocol> {
|
|
1209
|
+
Factory(self) { UserRepository() }
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
@Observable
|
|
1214
|
+
@MainActor
|
|
1215
|
+
class UserListViewModel {
|
|
1216
|
+
@ObservationIgnored @Injected(\.userRepository)
|
|
1217
|
+
private var userRepository
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
struct UserListView: View {
|
|
1221
|
+
@State private var viewModel = UserListViewModel()
|
|
1222
|
+
// userRepository injected automatically by Factory
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// OR: Pass from parent that has access
|
|
1226
|
+
struct ParentView: View {
|
|
1227
|
+
@Environment(\.userRepository) private var userRepository
|
|
1228
|
+
|
|
1229
|
+
var body: some View {
|
|
1230
|
+
UserListView(
|
|
1231
|
+
viewModel: UserListViewModel(userRepository: userRepository)
|
|
1232
|
+
)
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
```
|
|
1236
|
+
</anti_pattern>
|
|
1237
|
+
|
|
1238
|
+
<anti_pattern name="God Objects / Service Locators">
|
|
1239
|
+
**Problem:** Creating one massive dependency container that knows about everything
|
|
1240
|
+
|
|
1241
|
+
```swift
|
|
1242
|
+
// DON'T: God object with all dependencies
|
|
1243
|
+
class AppDependencies {
|
|
1244
|
+
let apiClient: APIClient
|
|
1245
|
+
let userRepository: UserRepository
|
|
1246
|
+
let postRepository: PostRepository
|
|
1247
|
+
let authService: AuthService
|
|
1248
|
+
let cacheService: CacheService
|
|
1249
|
+
let analyticsService: AnalyticsService
|
|
1250
|
+
let pushService: PushService
|
|
1251
|
+
let locationService: LocationService
|
|
1252
|
+
// ... 50 more dependencies
|
|
1253
|
+
|
|
1254
|
+
init() {
|
|
1255
|
+
// Complex initialization graph
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Passed everywhere
|
|
1260
|
+
struct UserListView: View {
|
|
1261
|
+
let dependencies: AppDependencies
|
|
1262
|
+
}
|
|
1263
|
+
```
|
|
1264
|
+
|
|
1265
|
+
**Why it's bad:**
|
|
1266
|
+
- Views depend on entire app graph (not just what they need)
|
|
1267
|
+
- Hard to test (must construct entire AppDependencies)
|
|
1268
|
+
- Poor encapsulation
|
|
1269
|
+
- Merge conflicts on AppDependencies class
|
|
1270
|
+
|
|
1271
|
+
**Instead:** Use Factory with containers or inject specific dependencies
|
|
1272
|
+
|
|
1273
|
+
```swift
|
|
1274
|
+
// DO: Factory with automatic resolution
|
|
1275
|
+
extension Container {
|
|
1276
|
+
var userRepository: Factory<UserRepositoryProtocol> {
|
|
1277
|
+
Factory(self) { UserRepository(apiClient: self.apiClient()) }
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
@Observable
|
|
1282
|
+
@MainActor
|
|
1283
|
+
class UserListViewModel {
|
|
1284
|
+
@ObservationIgnored @Injected(\.userRepository)
|
|
1285
|
+
private var userRepository // Only knows about userRepository
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Or inject specific dependencies
|
|
1289
|
+
struct UserListView: View {
|
|
1290
|
+
@State private var viewModel: UserListViewModel
|
|
1291
|
+
|
|
1292
|
+
init(userRepository: UserRepositoryProtocol) {
|
|
1293
|
+
_viewModel = State(initialValue: UserListViewModel(
|
|
1294
|
+
userRepository: userRepository
|
|
1295
|
+
))
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
```
|
|
1299
|
+
</anti_pattern>
|
|
1300
|
+
</anti_patterns>
|
|
1301
|
+
|
|
1302
|
+
<project_structure>
|
|
1303
|
+
## Recommended Project Structure
|
|
1304
|
+
|
|
1305
|
+
### Small Apps (5-15 screens, single developer)
|
|
1306
|
+
|
|
1307
|
+
```
|
|
1308
|
+
MyApp/
|
|
1309
|
+
├── MyApp.swift # @main App entry point
|
|
1310
|
+
├── Models/
|
|
1311
|
+
│ ├── User.swift
|
|
1312
|
+
│ ├── Post.swift
|
|
1313
|
+
│ └── Comment.swift
|
|
1314
|
+
├── Views/
|
|
1315
|
+
│ ├── UserList/
|
|
1316
|
+
│ │ ├── UserListView.swift
|
|
1317
|
+
│ │ └── UserRowView.swift
|
|
1318
|
+
│ ├── UserDetail/
|
|
1319
|
+
│ │ └── UserDetailView.swift
|
|
1320
|
+
│ └── Settings/
|
|
1321
|
+
│ └── SettingsView.swift
|
|
1322
|
+
├── ViewModels/ # If using MVVM
|
|
1323
|
+
│ ├── UserListViewModel.swift
|
|
1324
|
+
│ └── UserDetailViewModel.swift
|
|
1325
|
+
├── Services/
|
|
1326
|
+
│ ├── APIClient.swift
|
|
1327
|
+
│ └── CacheService.swift
|
|
1328
|
+
├── Utilities/
|
|
1329
|
+
│ ├── Extensions.swift
|
|
1330
|
+
│ └── Constants.swift
|
|
1331
|
+
└── Resources/
|
|
1332
|
+
├── Assets.xcassets
|
|
1333
|
+
└── Localizable.strings
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
**Key principles:**
|
|
1337
|
+
- Flat structure with minimal nesting
|
|
1338
|
+
- Group by feature (UserList, UserDetail) not layer
|
|
1339
|
+
- ViewModels folder only if using MVVM
|
|
1340
|
+
- Services for shared business logic
|
|
1341
|
+
|
|
1342
|
+
### Medium Apps (15-50 screens, 2-5 developers)
|
|
1343
|
+
|
|
1344
|
+
```
|
|
1345
|
+
MyApp/
|
|
1346
|
+
├── MyApp.swift
|
|
1347
|
+
├── App/
|
|
1348
|
+
│ ├── DependencyContainer.swift
|
|
1349
|
+
│ └── AppCoordinator.swift
|
|
1350
|
+
├── Features/
|
|
1351
|
+
│ ├── Authentication/
|
|
1352
|
+
│ │ ├── Views/
|
|
1353
|
+
│ │ │ ├── LoginView.swift
|
|
1354
|
+
│ │ │ └── SignupView.swift
|
|
1355
|
+
│ │ ├── ViewModels/
|
|
1356
|
+
│ │ │ └── AuthViewModel.swift
|
|
1357
|
+
│ │ └── Models/
|
|
1358
|
+
│ │ └── AuthState.swift
|
|
1359
|
+
│ ├── UserList/
|
|
1360
|
+
│ │ ├── Views/
|
|
1361
|
+
│ │ │ ├── UserListView.swift
|
|
1362
|
+
│ │ │ └── UserRowView.swift
|
|
1363
|
+
│ │ ├── ViewModels/
|
|
1364
|
+
│ │ │ └── UserListViewModel.swift
|
|
1365
|
+
│ │ └── Models/
|
|
1366
|
+
│ │ └── User.swift
|
|
1367
|
+
│ ├── UserDetail/
|
|
1368
|
+
│ │ ├── Views/
|
|
1369
|
+
│ │ ├── ViewModels/
|
|
1370
|
+
│ │ └── Models/
|
|
1371
|
+
│ └── Settings/
|
|
1372
|
+
│ ├── Views/
|
|
1373
|
+
│ └── ViewModels/
|
|
1374
|
+
├── Core/
|
|
1375
|
+
│ ├── Networking/
|
|
1376
|
+
│ │ ├── APIClient.swift
|
|
1377
|
+
│ │ ├── Endpoint.swift
|
|
1378
|
+
│ │ └── NetworkError.swift
|
|
1379
|
+
│ ├── Persistence/
|
|
1380
|
+
│ │ └── CacheService.swift
|
|
1381
|
+
│ ├── Extensions/
|
|
1382
|
+
│ │ ├── View+Extensions.swift
|
|
1383
|
+
│ │ └── String+Extensions.swift
|
|
1384
|
+
│ └── UI/
|
|
1385
|
+
│ ├── LoadingView.swift
|
|
1386
|
+
│ └── ErrorView.swift
|
|
1387
|
+
└── Resources/
|
|
1388
|
+
├── Assets.xcassets
|
|
1389
|
+
└── Localizable.strings
|
|
1390
|
+
```
|
|
1391
|
+
|
|
1392
|
+
**Key principles:**
|
|
1393
|
+
- Features folder with clear feature modules
|
|
1394
|
+
- Each feature has Views/ViewModels/Models subfolders
|
|
1395
|
+
- Core folder for shared infrastructure
|
|
1396
|
+
- App folder for composition root
|
|
1397
|
+
- Still single-target Xcode project
|
|
1398
|
+
|
|
1399
|
+
### Large Apps (50+ screens, 5+ developers, multi-platform)
|
|
1400
|
+
|
|
1401
|
+
Use Swift Package Manager with modular architecture:
|
|
1402
|
+
|
|
1403
|
+
```
|
|
1404
|
+
MyApp/
|
|
1405
|
+
├── App/
|
|
1406
|
+
│ ├── MyApp/
|
|
1407
|
+
│ │ ├── MyApp.swift
|
|
1408
|
+
│ │ ├── DependencyContainer.swift
|
|
1409
|
+
│ │ └── AppCoordinator.swift
|
|
1410
|
+
│ └── MyApp.xcodeproj
|
|
1411
|
+
├── Packages/
|
|
1412
|
+
│ ├── Domain/
|
|
1413
|
+
│ │ ├── Package.swift
|
|
1414
|
+
│ │ └── Sources/Domain/
|
|
1415
|
+
│ │ ├── Models/
|
|
1416
|
+
│ │ │ ├── User.swift
|
|
1417
|
+
│ │ │ └── Post.swift
|
|
1418
|
+
│ │ └── Repositories/
|
|
1419
|
+
│ │ ├── UserRepositoryProtocol.swift
|
|
1420
|
+
│ │ └── PostRepositoryProtocol.swift
|
|
1421
|
+
│ ├── Data/
|
|
1422
|
+
│ │ ├── Package.swift
|
|
1423
|
+
│ │ └── Sources/Data/
|
|
1424
|
+
│ │ ├── Repositories/
|
|
1425
|
+
│ │ │ ├── UserRepository.swift
|
|
1426
|
+
│ │ │ └── PostRepository.swift
|
|
1427
|
+
│ │ ├── Networking/
|
|
1428
|
+
│ │ │ ├── APIClient.swift
|
|
1429
|
+
│ │ │ └── DTOs/
|
|
1430
|
+
│ │ └── Persistence/
|
|
1431
|
+
│ │ └── CoreDataStack.swift
|
|
1432
|
+
│ ├── FeatureUserList/
|
|
1433
|
+
│ │ ├── Package.swift
|
|
1434
|
+
│ │ └── Sources/FeatureUserList/
|
|
1435
|
+
│ │ ├── Views/
|
|
1436
|
+
│ │ │ ├── UserListView.swift
|
|
1437
|
+
│ │ │ └── UserRowView.swift
|
|
1438
|
+
│ │ └── ViewModels/
|
|
1439
|
+
│ │ └── UserListViewModel.swift
|
|
1440
|
+
│ ├── FeatureUserDetail/
|
|
1441
|
+
│ │ ├── Package.swift
|
|
1442
|
+
│ │ └── Sources/FeatureUserDetail/
|
|
1443
|
+
│ │ └── ...
|
|
1444
|
+
│ ├── FeatureSettings/
|
|
1445
|
+
│ │ ├── Package.swift
|
|
1446
|
+
│ │ └── Sources/FeatureSettings/
|
|
1447
|
+
│ │ └── ...
|
|
1448
|
+
│ ├── CoreUI/
|
|
1449
|
+
│ │ ├── Package.swift
|
|
1450
|
+
│ │ └── Sources/CoreUI/
|
|
1451
|
+
│ │ ├── Components/
|
|
1452
|
+
│ │ │ ├── LoadingView.swift
|
|
1453
|
+
│ │ │ └── ErrorView.swift
|
|
1454
|
+
│ │ ├── Extensions/
|
|
1455
|
+
│ │ └── Theme/
|
|
1456
|
+
│ └── CoreUtilities/
|
|
1457
|
+
│ ├── Package.swift
|
|
1458
|
+
│ └── Sources/CoreUtilities/
|
|
1459
|
+
│ ├── Extensions/
|
|
1460
|
+
│ └── Logging/
|
|
1461
|
+
└── Tests/
|
|
1462
|
+
├── DomainTests/
|
|
1463
|
+
├── DataTests/
|
|
1464
|
+
├── FeatureUserListTests/
|
|
1465
|
+
└── ...
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
**Package.swift example (FeatureUserList):**
|
|
1469
|
+
```swift
|
|
1470
|
+
// swift-tools-version: 5.9
|
|
1471
|
+
import PackageDescription
|
|
1472
|
+
|
|
1473
|
+
let package = Package(
|
|
1474
|
+
name: "FeatureUserList",
|
|
1475
|
+
platforms: [.iOS(.v17), .macOS(.v14)],
|
|
1476
|
+
products: [
|
|
1477
|
+
.library(
|
|
1478
|
+
name: "FeatureUserList",
|
|
1479
|
+
targets: ["FeatureUserList"]
|
|
1480
|
+
),
|
|
1481
|
+
],
|
|
1482
|
+
dependencies: [
|
|
1483
|
+
.package(path: "../Domain"),
|
|
1484
|
+
.package(path: "../CoreUI"),
|
|
1485
|
+
],
|
|
1486
|
+
targets: [
|
|
1487
|
+
.target(
|
|
1488
|
+
name: "FeatureUserList",
|
|
1489
|
+
dependencies: [
|
|
1490
|
+
"Domain",
|
|
1491
|
+
"CoreUI",
|
|
1492
|
+
]
|
|
1493
|
+
),
|
|
1494
|
+
.testTarget(
|
|
1495
|
+
name: "FeatureUserListTests",
|
|
1496
|
+
dependencies: ["FeatureUserList"]
|
|
1497
|
+
),
|
|
1498
|
+
]
|
|
1499
|
+
)
|
|
1500
|
+
```
|
|
1501
|
+
|
|
1502
|
+
**Dependency hierarchy (bottom to top):**
|
|
1503
|
+
```
|
|
1504
|
+
App (top level - depends on everything)
|
|
1505
|
+
├── Feature modules (depend on Domain, CoreUI, CoreUtilities)
|
|
1506
|
+
├── Data (depends on Domain)
|
|
1507
|
+
├── Domain (no dependencies - pure Swift)
|
|
1508
|
+
├── CoreUI (depends on CoreUtilities)
|
|
1509
|
+
└── CoreUtilities (no dependencies - pure Swift)
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
**Key principles:**
|
|
1513
|
+
- Features are isolated SPM packages
|
|
1514
|
+
- Each package can be opened and worked on independently
|
|
1515
|
+
- Faster builds (only changed packages rebuild)
|
|
1516
|
+
- Domain layer has no framework dependencies (pure Swift)
|
|
1517
|
+
- Data layer implements Domain protocols
|
|
1518
|
+
- Features depend only on Domain (not on each other)
|
|
1519
|
+
- App layer composes everything
|
|
1520
|
+
|
|
1521
|
+
**Benefits:**
|
|
1522
|
+
- Teams work on separate packages with minimal conflicts
|
|
1523
|
+
- Removing features = delete package folder
|
|
1524
|
+
- Faster SwiftUI previews (don't build unrelated code)
|
|
1525
|
+
- Enforced architectural boundaries via dependencies
|
|
1526
|
+
- Testable in isolation
|
|
1527
|
+
|
|
1528
|
+
**When to modularize:**
|
|
1529
|
+
- More than 50 screens
|
|
1530
|
+
- More than 5 developers
|
|
1531
|
+
- Multiple platforms (iOS, macOS, watchOS)
|
|
1532
|
+
- When build times exceed 2-3 minutes
|
|
1533
|
+
</project_structure>
|
|
1534
|
+
|
|
1535
|
+
## Sources
|
|
1536
|
+
|
|
1537
|
+
- [Hacking with Swift: MVVM in SwiftUI](https://www.hackingwithswift.com/books/ios-swiftui/introducing-mvvm-into-your-swiftui-project)
|
|
1538
|
+
- [Medium: Modern MVVM in SwiftUI 2025](https://medium.com/@minalkewat/modern-mvvm-in-swiftui-2025-the-clean-architecture-youve-been-waiting-for-72a7d576648e)
|
|
1539
|
+
- [SwiftLee: MVVM Architectural Pattern](https://www.avanderlee.com/swiftui/mvvm-architectural-coding-pattern-to-structure-views/)
|
|
1540
|
+
- [Medium: SwiftUI in 2025 - Forget MVVM](https://dimillian.medium.com/swiftui-in-2025-forget-mvvm-262ff2bbd2ed)
|
|
1541
|
+
- [Alexey Naumov: Clean Architecture for SwiftUI](https://nalexn.github.io/clean-architecture-swiftui/)
|
|
1542
|
+
- [Medium: 2025's Best SwiftUI Architecture](https://medium.com/@minalkewat/2025s-best-swiftui-architecture-mvvm-clean-feature-modules-3a369a22858c)
|
|
1543
|
+
- [GitHub: Clean Architecture SwiftUI](https://github.com/nalexn/clean-architecture-swiftui)
|
|
1544
|
+
- [Medium: MVVM with Organized Folder Structures](https://medium.com/@rogeriocpires_128/implementing-mvvm-in-swiftui-with-organized-folder-structures-bc86845eead8)
|
|
1545
|
+
- [mokacoding: Dependency Injection in SwiftUI](https://mokacoding.com/blog/swiftui-dependency-injection/)
|
|
1546
|
+
- [Lucas van Dongen: Managing Dependencies in SwiftUI](https://lucasvandongen.dev/dependency_injection_swift_swiftui.php)
|
|
1547
|
+
- [GitHub: Factory - Swift Dependency Injection](https://github.com/hmlongco/Factory)
|
|
1548
|
+
- [Jesse Squires: @Observable Macro Deep Dive](https://www.jessesquires.com/blog/2024/09/09/swift-observable-macro/)
|
|
1549
|
+
- [SwiftLee: @Observable Performance](https://www.avanderlee.com/swiftui/observable-macro-performance-increase-observableobject/)
|
|
1550
|
+
- [Apple: Migrating to @Observable](https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro)
|
|
1551
|
+
- [Donny Wals: @Observable Explained](https://www.donnywals.com/observable-in-swiftui-explained/)
|
|
1552
|
+
- [Nimble: Modularizing iOS Apps with SwiftUI and SPM](https://nimblehq.co/blog/modern-approach-modularize-ios-swiftui-spm)
|
|
1553
|
+
- [Medium: Building Large-Scale Apps with SwiftUI](https://azamsharp.medium.com/building-large-scale-apps-with-swiftui-a-guide-to-modular-architecture-9c967be13001)
|
|
1554
|
+
- [Medium: Modular SwiftUI Architecture](https://medium.com/@pavel-holec/swiftui-modular-architecture-9bb1647b70b8)
|
|
1555
|
+
- [Better Programming: Factory Dependency Injection](https://betterprogramming.pub/factory-swift-dependency-injection-14da9b2b5d09)
|
|
1556
|
+
- [Lucas van Dongen: DI Frameworks Compared](https://lucasvandongen.dev/di_frameworks_compared.php)
|
|
1557
|
+
- [Medium: App vs Scene Protocol](https://medium.com/@ksjadhav2699/swiftui-app-vs-scene-protocol-1022e655a1fc)
|
|
1558
|
+
- [Swift with Majid: Managing App in SwiftUI](https://swiftwithmajid.com/2020/08/19/managing-app-in-swiftui/)
|
|
1559
|
+
- [GitHub: Swift Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture)
|
|
1560
|
+
- [InfoQ: Swift Composable Architecture](https://www.infoq.com/news/2024/08/swift-composable-architecture/)
|
|
1561
|
+
- [Rod Schmidt: TCA 3 Year Experience](https://rodschmidt.com/posts/composable-architecture-experience/)
|