From 48596a0a9ab2d4d05287759d3b38dbb4bc3ebaec Mon Sep 17 00:00:00 2001 From: ARC Labs Studio Date: Sat, 21 Mar 2026 01:34:54 +0100 Subject: [PATCH] docs(standards): add @Environment DI guidance and fix @MainActor patterns (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): add Claude GitHub Actions workflows (#40) - Add claude.yml: Respond to @claude mentions in issues/PRs - Add claude-code-review.yml: Automated PR code reviews - Follows ARC Labs Swift standards - Configured with CLAUDE_CODE_OAUTH_TOKEN secret * Feature/skills review (#44) * feat(skills): add worktrees workflow skill for parallel development * feat(skills): add memory and worktrees workflow skills * docs(skills): add skills index for iOS/Swift development Create comprehensive skills index document that maps when to use each skill source (ARC Labs, Van der Lee, Axiom) for iOS/Swift development tasks. Includes quick decision guide, coverage gaps matrix, and common scenario recommendations. * feat(skills): add iOS 26 Liquid Glass and @Previewable patterns Update arc-presentation-layer skill with: - iOS 26 Liquid Glass material effects (.glassEffect modifier) - Backward compatible glass implementations - Glass effect tinting patterns - Toolbar Liquid Glass treatment - @Previewable macro for SwiftUI previews (iOS 18+) - Preview best practices with multiple states - Updated related skills table with Axiom iOS 26 references * feat(skills): add memory and worktrees workflow skills * feat(skills): add arc-final-review skill for pre-merge quality checks Add comprehensive final review skill inspired by Staff iOS Engineer review patterns. The skill: - Analyzes changes by domain (SwiftUI, Concurrency, Data, Architecture) - Invokes specialized Axiom skills for each domain - Generates prioritized finalization plan with verification gates - Identifies tech debt cleanup items - Provides merge recommendation Also updates CLAUDE.md to document the new skill in the workflow. * docs: add Swift 6 concurrency patterns and Clean Architecture learnings FVRS-73 Clean Architecture audit documented key patterns: Testing: - @MainActor isolation requirements for test structs - Mock extension isolation for Swift 6 compatibility - Tag centralization to avoid "ambiguous use" errors - Mock factory best practices (avoiding false matches) Presentation: - @Observable + lazy var incompatibility (use IUOs + init) - Composition Root pattern for AppCoordinator DI Architecture: - ISP: Reader/Writer protocol separation for repositories - Pure Use Cases (stateless, no dependencies) - Real-world FVRS-73 ISP example with Toggle/Get/Filter use cases * chore(security): add ARC Labs security patterns to .gitignore * Feature/audit configuration (#51) * docs(standards): rewrite CLAUDE.md with comprehensive agent guide - Add presentation layer rules (no business logic in Views or ViewModels) - Add UseCase patterns (single-responsibility + grouped with enum actions) - Add concurrency guidelines (@MainActor per-method only, never blanket) - Add multiline declaration formatting (after-first style) - Add private extension pattern for private methods - Add ARCKnowledge access configuration via submodule chain - Add complementary skills section (Axiom, Van der Lee, MCP Cupertino) - Update critical rules from 13 to 15 - Add periodic review section with /arc-audit * docs(architecture): update ViewModel, UseCase and concurrency patterns - Remove blanket @MainActor from ViewModel examples - Add @MainActor per-method on UI-bound state methods only - Add Sendable conformance to UseCase protocols and implementations - Add grouped UseCase pattern with enum actions - Add concurrency guidelines section (WWDC 2025-268) - Update anti-patterns list * docs(presentation): apply @MainActor per-method to ViewModel examples - Remove blanket @MainActor from UserProfileViewModel - Add @MainActor to onAppear(), onTappedRetry(), loadProfile() methods - Remove blanket @MainActor from ContentViewModel and HomeViewModel - Update description to emphasize ViewModels contain NO business logic * docs(testing): add mandatory UseCase testing and update ViewModel tests - Add "Testing Use Cases (Mandatory)" section with full example - Update ViewModel test examples: @MainActor per-method, not on @Suite - Cover business rules, error paths, and validation in UseCase tests * docs(quality): add multiline formatting and private extension patterns - Add multiline declarations section (after-first style) - Add private extension pattern section - Update SwiftFormat config with --wrapparameters, --wrapcollections, --closingparen balanced - Update MARK section ordering in code-style.md - Remove redundant private extension for String type * docs(domain): add grouped UseCase pattern and Sendable conformance - Add grouped UseCase pattern with enum actions - Add Sendable conformance to UseCase protocol and implementation - Move private methods to private extension pattern - Add testing requirement for all UseCases - Update Domain Layer checklist with new requirements - Update MARK sections to new standard * docs(review): update checklists for @MainActor per-method and Sendable - Fix Architecture Checklist: no blanket @MainActor on ViewModels - Fix Concurrency Checklist: @MainActor per-method, Sendable on UseCases - Add private extension and UseCase testing checks * feat(skills): create /arc-audit skill for project standards compliance New comprehensive audit skill with: - 9 audit domains (Architecture, Presentation, Domain, Data, Testing, Code Style, Documentation, Accessibility, Concurrency) - Compliance grading (A-F) with severity levels - Scope support (full project, directory, single file) - Scan commands and patterns for each domain - Report template with findings and recommendations - Integration with complementary Axiom and ARC Labs skills * docs(index): add /arc-audit and /arc-final-review to skills index - Add /arc-audit and /arc-final-review to ARC Labs skills table - Add project standards audit to Quick Decision Guide - Update ARC Labs version info to Feb 2026 * Feature/skills improvement (#54) * refactor(skills): align arc-final-review with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, and add Examples section. * refactor(skills): align arc-audit with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, and add Examples section. * refactor(skills): align arc-memory with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, and add Examples section. * refactor(skills): align arc-worktrees-workflow with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, and add Examples section. * refactor(skills): align arc-data-layer with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, add Examples section, and move data.md to references/ subdirectory for progressive disclosure. * refactor(skills): align arc-tdd-patterns with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, add Examples section, and move testing.md to references/ subdirectory for progressive disclosure. * refactor(skills): align arc-presentation-layer with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, add Examples section, and move presentation.md to references/ subdirectory for progressive disclosure. * refactor(skills): align arc-workflow with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, add Examples section, and move git-branches.md, git-commits.md, plan-mode.md to references/ subdirectory. * refactor(skills): align arc-swift-architecture with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, add Examples section, and move 6 supplemental files to references/ subdirectory for progressive disclosure. * refactor(skills): align arc-quality-standards with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, add Examples section, and move 6 supplemental files to references/ subdirectory for progressive disclosure. * refactor(skills): align arc-project-setup with Anthropic skill guide Rewrite description field with trigger phrases, add metadata block, rename section headings, add Examples section, and move 5 supplemental files to references/ subdirectory for progressive disclosure. * Feature/arc xcode cloud (#57) * feat(ci): add arc-xcode-cloud skill and update skills index - Add .claude/skills/arc-xcode-cloud/SKILL.md with full Xcode Cloud setup guide - Update Skills/skills-index.md to include arc-xcode-cloud - Update Tools/xcode.md with Xcode Cloud section - Update CLAUDE.md skills table to include arc-xcode-cloud * docs: add arc-xcode-cloud to README skills table and documentation index * Feature/agents (#60) * feat(agents): add 6 ARC Labs subagents for autonomous task execution Introduces the agents system to ARCKnowledge. Each agent invokes skills dynamically based on the task rather than embedding all knowledge in the prompt. - arc-swift-tdd (sonnet): TDD implementation, writes tests before code - arc-swift-reviewer (sonnet): delegated code review, structured report - arc-swift-debugger (sonnet): build/test failure diagnosis, env-first - arc-spm-manager (haiku): Package.swift management and verification - arc-xcode-explorer (haiku): read-only codebase navigation and mapping - arc-linear-bridge (haiku): Linear ticket to Swift test scaffolding * docs(agents): add AGENTS.md index with trigger phrases and design principles * docs(skills): add Agents section to skills-index with routing table * docs(arc-final-review): clarify guided vs delegated review distinction * feat(arc-linear-bridge): create branch automatically after scaffolding Add mcp__ARC_Linear_GitHub__github_create_branch to tools. Execute git checkout -b locally as primary path; fall back to GitHub MCP if not in a git repo or branch already exists. * fix(arc-spm-manager): correct org URL and add ARCPurchasing, ARCAuthentication Fix GitHub org from arcdevtools → arclabs-studio. Add ARCPurchasing (RevenueCat IAP) and ARCAuthentication (SIWA + Vapor) to the known packages table with product names and domains. * feat(agents): add arc-pr-publisher and arc-release-orchestrator arc-pr-publisher: validates pre-PR checklist, creates GitHub PR, links Linear ticket, updates issue to "In Review". Closes the workflow gap between code review and merge. arc-release-orchestrator: bumps version, updates CHANGELOG, creates release branch and PR. Confirms version with user before edits. Reports manual steps (tag + App Store) after PR merge. * docs(agents): update index for 8-agent system and package table * feat(agents): add arc-testflight distribution agent Orchestrates archive → upload → TestFlight configuration. Distinct from arc-release-orchestrator (code/PR) — handles beta distribution only: tester groups, release notes, ExportOptions. * feat(agents): add arc-aso App Store Optimization agent Orchestrates 8 ASO skills (app-marketing-context, aso-audit, keyword-research, metadata-optimization, screenshot-optimization, app-store-featured, ab-test-store-listing, app-launch). Produces ready-to-upload metadata files in aso/[bundle-id]/. * feat(agents): add arc-swiftdata-migration high-risk schema agent Most conservative agent in the system. Classifies changes as lightweight vs custom, confirms before any breaking change, and enforces test-before-code for all migration plans. Invokes swiftdata-pro, axiom migration skills, arc-data-layer, arc-tdd-patterns. * feat(agents): add arc-dependency-auditor read-only audit agent Audits SPM dependencies across the project ecosystem. Detects outdated packages, version inconsistencies, and branch/revision pins. Produces a prioritized report; delegates updates to arc-spm-manager. * docs(agents): update AGENTS.md from 8 to 12 agents Add entries for arc-testflight, arc-aso, arc-swiftdata-migration, arc-dependency-auditor. Update master skills/MCPs table with all 4 new agents and their skill/MCP dependencies. * docs(skills): add 4 new agents to skills-index agents section Add arc-testflight, arc-aso, arc-swiftdata-migration, arc-dependency-auditor to the ARC Labs Agents table with model selection and trigger phrases. * Feature/swift solid pattern (#65) * docs(architecture): add swift-design-principles foundational document Six Swift-native design principles for ARC Labs Studio: Value Semantics by Default, Protocol-Driven Abstraction, Composition Over Inheritance, Well-Defined Ownership, Structured Concurrency, and Compile-Time Correctness. Includes SOLID mapping table, anti-patterns section, and full references (WWDC, Swift Evolution, Apple docs). Motivated by Fernández Muñoz's critique of SOLID in Swift and two prior sessions that refined the position: SOLID is not dead but its intent is expressed through Swift-native vocabulary and mechanisms. Co-located reference copy added to arc-swift-architecture skill references. * docs(architecture): add cross-references to swift-design-principles - solid-principles.md: add ARC Labs context note positioning SOLID as a reference framework interpreted through the Swift design principles lens - protocol-oriented.md: add cross-reference to swift-design-principles as the broader philosophical framework * docs(skills): update arc-swift-architecture with swift design principles Replace the SOLID Quick Guide section with the six Swift design principles summary. Add swift-design-principles.md as the primary architecture reference, positioned before clean-architecture.md. * docs(claude): surface swift design principles in agent guide and index - CLAUDE.md: replace bare "SOLID Principles" technical principle with the six Swift design principles and a link to swift-design-principles.md; SOLID remains referenced as a useful historical framework - Skills/skills-index.md: update arc-swift-architecture entry to mention Swift design principles alongside MVVM+C * chore(release): prepare v2.10.0 - README.md: add swift-design-principles.md to Architecture directory listing and arc-swift-architecture skill files table - CHANGELOG.md: document v2.10.0 with all added and changed files * docs(standards): add @Environment DI guidance and fix @MainActor patterns - Add Dependency Injection Strategy section to presentation.md with a decision matrix (init injection vs @Environment), @Entry macro (iOS 18+), type-based @Environment for @Observable (iOS 17+), anti-patterns, and @EnvironmentObject deprecation note - Fix @MainActor inconsistency: remove blanket annotation from ViewModel class declarations; add it only to methods that await nonisolated code and write to @Observable state (matches CLAUDE.md rule #14) - Add @MainActor Placement explanation section backed by SE-0316 and SE-0466 (Swift 6.2 DefaultIsolation), clarifying why class-level is wrong for packages and when method-level annotation is required - Remove redundant @MainActor from pure-delegation methods (onAppear, onTappedRetry) — the main-actor hop happens inside the callee - Restore private modifier on loadRestaurants example - Add Router @Environment rationale callout to mvvm-c.md - Add cross-reference and DIP row clarification to swift-design-principles.md - Sync all changes to skill reference copies --- .../references/presentation.md | 180 +++++++++++++++--- .../references/mvvm-c.md | 2 + .../references/swift-design-principles.md | 4 +- Architecture/mvvm-c.md | 2 + Architecture/swift-design-principles.md | 4 +- Layers/presentation.md | 168 +++++++++++++--- 6 files changed, 306 insertions(+), 54 deletions(-) diff --git a/.claude/skills/arc-presentation-layer/references/presentation.md b/.claude/skills/arc-presentation-layer/references/presentation.md index a462fa8..94bc211 100644 --- a/.claude/skills/arc-presentation-layer/references/presentation.md +++ b/.claude/skills/arc-presentation-layer/references/presentation.md @@ -310,7 +310,6 @@ import ARCLogger import ARCNavigation import Foundation -@MainActor @Observable final class UserProfileViewModel { @@ -350,25 +349,26 @@ final class UserProfileViewModel { } // MARK: Lifecycle - + func onAppear() async { await loadProfile() } - + // MARK: Public Functions - + func onTappedEditProfile() { guard let user = user else { return } - + ARCLogger.shared.info("User tapped edit profile") router.navigate(to: .editProfile(user)) } - + + @MainActor func onTappedSignOut() async { ARCLogger.shared.info("User requested sign out") - + isLoading = true - + do { try await signOutUseCase.execute() router.popToRoot() @@ -379,10 +379,10 @@ final class UserProfileViewModel { "error": error.localizedDescription ]) } - + isLoading = false } - + func onTappedRetry() async { await loadProfile() } @@ -390,11 +390,12 @@ final class UserProfileViewModel { // MARK: - Private Functions -extension UserProfileViewModel { +private extension UserProfileViewModel { + @MainActor func loadProfile() async { isLoading = true errorMessage = nil - + do { user = try await getUserProfileUseCase.execute() ARCLogger.shared.debug("Profile loaded successfully") @@ -404,7 +405,7 @@ extension UserProfileViewModel { "error": error.localizedDescription ]) } - + isLoading = false } } @@ -469,6 +470,43 @@ final class AppCoordinator { --- +#### @MainActor Placement: Why Methods, Not the Class + +`@MainActor` on a **class** isolates every member — all stored properties, all methods, and `init` — to the main actor. This is a blanket constraint that forces even non-UI methods to hop to the main thread on every call, adds overhead, and prevents packages from being called from non-main-actor contexts without `await`. + +`@MainActor` on a **method** is targeted: after any `await` suspension point, the runtime guarantees execution returns to the main actor before continuing. This is what you need when a method awaits nonisolated async code and then writes to `@Observable` properties that drive UI. + +```swift +// ✅ Correct: @MainActor only where the write-after-await happens +@Observable +final class UserViewModel { + private(set) var user: User? + + // loadUser awaits a nonisolated UseCase, then writes to `user`. + // @MainActor guarantees the write happens on the main actor. + @MainActor + func loadUser() async { + user = try? await getUserUseCase.execute() + } + + // Pure delegation — the @MainActor hop happens inside loadUser. + // No annotation needed here. + func onAppear() async { + await loadUser() + } +} + +// ❌ Wrong: Blanket @MainActor — all methods locked to main thread, +// prevents calling from background actors without await overhead. +@MainActor +@Observable +final class UserViewModel { ... } +``` + +> **Swift 6.2 note (SE-0466)**: App targets can opt into `DefaultIsolation = @MainActor` via a build setting, which infers `@MainActor` for all non-explicitly-isolated code in the module. This is a valid alternative for apps. For **packages**, it is inappropriate — callers may be off the main actor. Per-method annotation is always safe for both. + +--- + #### 1. Use Enums for Complex State ```swift @@ -479,7 +517,6 @@ enum LoadingState: Equatable { case error(String) } -@MainActor @Observable final class RestaurantListViewModel { private(set) var state: LoadingState<[Restaurant]> = .idle @@ -503,13 +540,12 @@ final class RestaurantListViewModel { #### 2. Private(set) for Mutable State ```swift -@MainActor @Observable final class SearchViewModel { // ✅ Good: Private setter private(set) var results: [Restaurant] = [] private(set) var isSearching = false - + // ❌ Bad: Public mutable state var results: [Restaurant] = [] } @@ -518,16 +554,15 @@ final class SearchViewModel { #### 3. Method Naming Convention ```swift -@MainActor @Observable final class HomeViewModel { // ✅ Good: Prefix with "on" for user actions func onTappedRestaurant(_ restaurant: Restaurant) { ... } func onChangedSearchText(_ text: String) { ... } func onAppear() { } - + // ✅ Good: Standard naming for internal methods - private func loadRestaurants() async { ... } + @MainActor private func loadRestaurants() async { ... } private func formatDate(_ date: Date) -> String { ... } } ``` @@ -698,6 +733,93 @@ final class AppCoordinator { --- +### Dependency Injection Strategy + +ARC Labs uses two complementary DI mechanisms. Choosing the right one keeps layers clean and tests simple. + +#### Decision Matrix + +| Dependency | Mechanism | Why | +|---|---|---| +| Use Cases → ViewModel | Init injection (protocol) | Testability; Domain layer abstraction | +| Repositories → Use Case | Init injection (protocol) | Testability; Data layer abstraction | +| Router → View | `@Environment(Router.self)` | `@Observable`, shared across deep hierarchy | +| Router → ViewModel | Init injection | Unit testability | +| Shared app model (e.g., `UserSession`) → View | `@Environment(Type.self)` | `@Observable`, avoids threading through every init | +| System values (`colorScheme`, `reduceMotion`) | `@Environment(\.keyPath)` | SwiftUI built-in key paths | +| Services, API clients | Init injection (protocol) | Not `@Observable`; testability | + +#### The Rule + +`@Environment` is a **delivery mechanism** for Presentation-layer `@Observable` models. It does **not** replace the Composition Root — the `AppCoordinator` still creates and wires all dependencies. `.environment()` is how some of those objects reach deep Views without threading through every intermediate View's init. + +> Init injection remains the **primary** DI mechanism for Domain and Data layers. `@Environment` is strictly a Presentation-layer concern. + +#### Type-Based `@Environment` for @Observable (iOS 17+) + +The Router pattern generalises to any `@Observable` model that needs to be shared across a deep view hierarchy: + +```swift +// Composition Root — inject into environment once +WindowGroup { + ContentView() + .environment(userSession) // userSession: UserSession (@Observable) + .withRouter(router) +} + +// Any descendant View — read from environment +struct ProfileView: View { + @Environment(UserSession.self) private var userSession + // ... +} +``` + +#### `@Entry` Macro for Custom Environment Keys (iOS 18+) + +The `@Entry` macro eliminates the boilerplate of `EnvironmentKey` conformances: + +```swift +// Before @Entry (iOS 17 and earlier) +private struct UserSessionKey: EnvironmentKey { + static let defaultValue: UserSession? = nil +} + +extension EnvironmentValues { + var userSession: UserSession? { + get { self[UserSessionKey.self] } + set { self[UserSessionKey.self] = newValue } + } +} + +// After @Entry (iOS 18+) +extension EnvironmentValues { + @Entry var userSession: UserSession? +} +``` + +#### Anti-Patterns + +**Never inject these via `@Environment`**: + +```swift +// ❌ Use Cases via @Environment — breaks testability, violates layer boundaries +@Environment(GetRestaurantsUseCase.self) private var getRestaurantsUseCase + +// ❌ Repositories via @Environment — same issues +@Environment(RestaurantRepositoryImpl.self) private var repository + +// ❌ Non-@Observable services — they don't participate in SwiftUI's update cycle +@Environment(NetworkService.self) private var networkService +``` + +Use init injection for all Domain and Data layer dependencies. `@Environment` is reserved for `@Observable` models that need to propagate across the Presentation layer. + +#### `@EnvironmentObject` Deprecation + +`@EnvironmentObject` is superseded by `@Environment(Type.self)` when the model conforms to `@Observable` (iOS 17+). ARC Labs code targeting iOS 17+ **must not** use `@EnvironmentObject`. The `@Observable` macro provides the same propagation mechanism with better performance and compile-time safety. + +--- + ### Feature-Specific Router (for complex features) ```swift @@ -707,25 +829,25 @@ import ARCNavigation @MainActor @Observable final class RestaurantFlowRouter { - + private let appRouter: Router - + init(appRouter: Router) { self.appRouter = appRouter } - + func showRestaurantList() { appRouter.navigate(to: .home) } - + func showRestaurantDetail(_ restaurant: Restaurant) { appRouter.navigate(to: .restaurantDetail(restaurant)) } - + func showRestaurantSearch(query: String? = nil) { appRouter.navigate(to: .search(query: query)) } - + func dismissFlow() { appRouter.popToRoot() } @@ -774,19 +896,19 @@ Button("Load Profile") { } // 2. ViewModel Coordinates -@MainActor @Observable final class ProfileViewModel { + @MainActor func onTappedLoadProfile() async { isLoading = true - + do { // Call Use Case user = try await getUserProfileUseCase.execute(userId: currentUserId) } catch { errorMessage = error.localizedDescription } - + isLoading = false } } @@ -845,7 +967,7 @@ final class UserRepositoryImpl: UserRepositoryProtocol { ### ViewModels - [ ] State is `private(set)` - [ ] Dependencies injected via init -- [ ] Uses `@Observable` and `@MainActor` +- [ ] Uses `@Observable`; `@MainActor` on specific methods only - [ ] User actions prefixed with "on" - [ ] Calls Use Cases (not Repositories directly) - [ ] Tells Router to navigate (doesn't navigate itself) diff --git a/.claude/skills/arc-swift-architecture/references/mvvm-c.md b/.claude/skills/arc-swift-architecture/references/mvvm-c.md index 2262743..8c68edd 100644 --- a/.claude/skills/arc-swift-architecture/references/mvvm-c.md +++ b/.claude/skills/arc-swift-architecture/references/mvvm-c.md @@ -119,6 +119,8 @@ struct FavResApp: App { The `.withRouter()` modifier automatically injects the Router into SwiftUI's environment, making it available to all child views. +> **Why `@Environment` for Router in Views?** Router is an `@Observable` model shared across the entire view hierarchy. `@Environment` avoids threading it through every intermediate View's init. ViewModels still receive Router via init injection for unit testability. + --- ## 📱 Navigation Patterns diff --git a/.claude/skills/arc-swift-architecture/references/swift-design-principles.md b/.claude/skills/arc-swift-architecture/references/swift-design-principles.md index c139f35..603d69c 100644 --- a/.claude/skills/arc-swift-architecture/references/swift-design-principles.md +++ b/.claude/skills/arc-swift-architecture/references/swift-design-principles.md @@ -206,6 +206,8 @@ Unclear ownership is the root cause of most state-related bugs: unexpected mutat **State ownership in SwiftUI**: `@State` (view owns it), `@Binding` (view borrows it), `@Observable` (external model owns it), `@Environment` (environment injects it). Each modifier makes the ownership relationship explicit and verifiable. +> See `Layers/presentation.md` § Dependency Injection Strategy for when to use `@Environment` vs init injection. + At ARC Labs, the relevant ownership rule is about state in the Presentation layer: the ViewModel owns the state it exposes to Views. Views never mutate ViewModel state directly. Use Cases never hold UI state. The Domain layer has no awareness of how its output is displayed. ### Swift 6 Expression @@ -408,7 +410,7 @@ SOLID was formulated in an OOP context (Java, C++, C#) where classes are the pri | **O** — Open/Closed | **Transformed** | Protocol conformances and extensions replace inheritance; new implementations extend without modifying | | **L** — Liskov Substitution | **Transformed** | Protocol contracts replace inheritance contracts; any conforming type substitutes any other | | **I** — Interface Segregation | **Dissolved** | Swift protocols are focused by design; standard library (Equatable, Hashable, Sendable) demonstrates this natively | -| **D** — Dependency Inversion | **Vigente, different mechanism** | Protocols + init injection + `@Environment`; same goal, no abstract base classes needed | +| **D** — Dependency Inversion | **Vigente, different mechanism** | Protocols + init injection (primary) + `@Environment` (Presentation layer, `@Observable` models); same goal, no abstract base classes needed | ### Honest Analysis by Principle diff --git a/Architecture/mvvm-c.md b/Architecture/mvvm-c.md index 2262743..8c68edd 100644 --- a/Architecture/mvvm-c.md +++ b/Architecture/mvvm-c.md @@ -119,6 +119,8 @@ struct FavResApp: App { The `.withRouter()` modifier automatically injects the Router into SwiftUI's environment, making it available to all child views. +> **Why `@Environment` for Router in Views?** Router is an `@Observable` model shared across the entire view hierarchy. `@Environment` avoids threading it through every intermediate View's init. ViewModels still receive Router via init injection for unit testability. + --- ## 📱 Navigation Patterns diff --git a/Architecture/swift-design-principles.md b/Architecture/swift-design-principles.md index c139f35..603d69c 100644 --- a/Architecture/swift-design-principles.md +++ b/Architecture/swift-design-principles.md @@ -206,6 +206,8 @@ Unclear ownership is the root cause of most state-related bugs: unexpected mutat **State ownership in SwiftUI**: `@State` (view owns it), `@Binding` (view borrows it), `@Observable` (external model owns it), `@Environment` (environment injects it). Each modifier makes the ownership relationship explicit and verifiable. +> See `Layers/presentation.md` § Dependency Injection Strategy for when to use `@Environment` vs init injection. + At ARC Labs, the relevant ownership rule is about state in the Presentation layer: the ViewModel owns the state it exposes to Views. Views never mutate ViewModel state directly. Use Cases never hold UI state. The Domain layer has no awareness of how its output is displayed. ### Swift 6 Expression @@ -408,7 +410,7 @@ SOLID was formulated in an OOP context (Java, C++, C#) where classes are the pri | **O** — Open/Closed | **Transformed** | Protocol conformances and extensions replace inheritance; new implementations extend without modifying | | **L** — Liskov Substitution | **Transformed** | Protocol contracts replace inheritance contracts; any conforming type substitutes any other | | **I** — Interface Segregation | **Dissolved** | Swift protocols are focused by design; standard library (Equatable, Hashable, Sendable) demonstrates this natively | -| **D** — Dependency Inversion | **Vigente, different mechanism** | Protocols + init injection + `@Environment`; same goal, no abstract base classes needed | +| **D** — Dependency Inversion | **Vigente, different mechanism** | Protocols + init injection (primary) + `@Environment` (Presentation layer, `@Observable` models); same goal, no abstract base classes needed | ### Honest Analysis by Principle diff --git a/Layers/presentation.md b/Layers/presentation.md index bf381c1..94bc211 100644 --- a/Layers/presentation.md +++ b/Layers/presentation.md @@ -310,7 +310,6 @@ import ARCLogger import ARCNavigation import Foundation -@MainActor @Observable final class UserProfileViewModel { @@ -350,25 +349,26 @@ final class UserProfileViewModel { } // MARK: Lifecycle - + func onAppear() async { await loadProfile() } - + // MARK: Public Functions - + func onTappedEditProfile() { guard let user = user else { return } - + ARCLogger.shared.info("User tapped edit profile") router.navigate(to: .editProfile(user)) } - + + @MainActor func onTappedSignOut() async { ARCLogger.shared.info("User requested sign out") - + isLoading = true - + do { try await signOutUseCase.execute() router.popToRoot() @@ -379,10 +379,10 @@ final class UserProfileViewModel { "error": error.localizedDescription ]) } - + isLoading = false } - + func onTappedRetry() async { await loadProfile() } @@ -390,11 +390,12 @@ final class UserProfileViewModel { // MARK: - Private Functions -extension UserProfileViewModel { +private extension UserProfileViewModel { + @MainActor func loadProfile() async { isLoading = true errorMessage = nil - + do { user = try await getUserProfileUseCase.execute() ARCLogger.shared.debug("Profile loaded successfully") @@ -404,7 +405,7 @@ extension UserProfileViewModel { "error": error.localizedDescription ]) } - + isLoading = false } } @@ -469,6 +470,43 @@ final class AppCoordinator { --- +#### @MainActor Placement: Why Methods, Not the Class + +`@MainActor` on a **class** isolates every member — all stored properties, all methods, and `init` — to the main actor. This is a blanket constraint that forces even non-UI methods to hop to the main thread on every call, adds overhead, and prevents packages from being called from non-main-actor contexts without `await`. + +`@MainActor` on a **method** is targeted: after any `await` suspension point, the runtime guarantees execution returns to the main actor before continuing. This is what you need when a method awaits nonisolated async code and then writes to `@Observable` properties that drive UI. + +```swift +// ✅ Correct: @MainActor only where the write-after-await happens +@Observable +final class UserViewModel { + private(set) var user: User? + + // loadUser awaits a nonisolated UseCase, then writes to `user`. + // @MainActor guarantees the write happens on the main actor. + @MainActor + func loadUser() async { + user = try? await getUserUseCase.execute() + } + + // Pure delegation — the @MainActor hop happens inside loadUser. + // No annotation needed here. + func onAppear() async { + await loadUser() + } +} + +// ❌ Wrong: Blanket @MainActor — all methods locked to main thread, +// prevents calling from background actors without await overhead. +@MainActor +@Observable +final class UserViewModel { ... } +``` + +> **Swift 6.2 note (SE-0466)**: App targets can opt into `DefaultIsolation = @MainActor` via a build setting, which infers `@MainActor` for all non-explicitly-isolated code in the module. This is a valid alternative for apps. For **packages**, it is inappropriate — callers may be off the main actor. Per-method annotation is always safe for both. + +--- + #### 1. Use Enums for Complex State ```swift @@ -479,7 +517,6 @@ enum LoadingState: Equatable { case error(String) } -@MainActor @Observable final class RestaurantListViewModel { private(set) var state: LoadingState<[Restaurant]> = .idle @@ -503,13 +540,12 @@ final class RestaurantListViewModel { #### 2. Private(set) for Mutable State ```swift -@MainActor @Observable final class SearchViewModel { // ✅ Good: Private setter private(set) var results: [Restaurant] = [] private(set) var isSearching = false - + // ❌ Bad: Public mutable state var results: [Restaurant] = [] } @@ -518,16 +554,15 @@ final class SearchViewModel { #### 3. Method Naming Convention ```swift -@MainActor @Observable final class HomeViewModel { // ✅ Good: Prefix with "on" for user actions func onTappedRestaurant(_ restaurant: Restaurant) { ... } func onChangedSearchText(_ text: String) { ... } func onAppear() { } - + // ✅ Good: Standard naming for internal methods - private func loadRestaurants() async { ... } + @MainActor private func loadRestaurants() async { ... } private func formatDate(_ date: Date) -> String { ... } } ``` @@ -698,6 +733,93 @@ final class AppCoordinator { --- +### Dependency Injection Strategy + +ARC Labs uses two complementary DI mechanisms. Choosing the right one keeps layers clean and tests simple. + +#### Decision Matrix + +| Dependency | Mechanism | Why | +|---|---|---| +| Use Cases → ViewModel | Init injection (protocol) | Testability; Domain layer abstraction | +| Repositories → Use Case | Init injection (protocol) | Testability; Data layer abstraction | +| Router → View | `@Environment(Router.self)` | `@Observable`, shared across deep hierarchy | +| Router → ViewModel | Init injection | Unit testability | +| Shared app model (e.g., `UserSession`) → View | `@Environment(Type.self)` | `@Observable`, avoids threading through every init | +| System values (`colorScheme`, `reduceMotion`) | `@Environment(\.keyPath)` | SwiftUI built-in key paths | +| Services, API clients | Init injection (protocol) | Not `@Observable`; testability | + +#### The Rule + +`@Environment` is a **delivery mechanism** for Presentation-layer `@Observable` models. It does **not** replace the Composition Root — the `AppCoordinator` still creates and wires all dependencies. `.environment()` is how some of those objects reach deep Views without threading through every intermediate View's init. + +> Init injection remains the **primary** DI mechanism for Domain and Data layers. `@Environment` is strictly a Presentation-layer concern. + +#### Type-Based `@Environment` for @Observable (iOS 17+) + +The Router pattern generalises to any `@Observable` model that needs to be shared across a deep view hierarchy: + +```swift +// Composition Root — inject into environment once +WindowGroup { + ContentView() + .environment(userSession) // userSession: UserSession (@Observable) + .withRouter(router) +} + +// Any descendant View — read from environment +struct ProfileView: View { + @Environment(UserSession.self) private var userSession + // ... +} +``` + +#### `@Entry` Macro for Custom Environment Keys (iOS 18+) + +The `@Entry` macro eliminates the boilerplate of `EnvironmentKey` conformances: + +```swift +// Before @Entry (iOS 17 and earlier) +private struct UserSessionKey: EnvironmentKey { + static let defaultValue: UserSession? = nil +} + +extension EnvironmentValues { + var userSession: UserSession? { + get { self[UserSessionKey.self] } + set { self[UserSessionKey.self] = newValue } + } +} + +// After @Entry (iOS 18+) +extension EnvironmentValues { + @Entry var userSession: UserSession? +} +``` + +#### Anti-Patterns + +**Never inject these via `@Environment`**: + +```swift +// ❌ Use Cases via @Environment — breaks testability, violates layer boundaries +@Environment(GetRestaurantsUseCase.self) private var getRestaurantsUseCase + +// ❌ Repositories via @Environment — same issues +@Environment(RestaurantRepositoryImpl.self) private var repository + +// ❌ Non-@Observable services — they don't participate in SwiftUI's update cycle +@Environment(NetworkService.self) private var networkService +``` + +Use init injection for all Domain and Data layer dependencies. `@Environment` is reserved for `@Observable` models that need to propagate across the Presentation layer. + +#### `@EnvironmentObject` Deprecation + +`@EnvironmentObject` is superseded by `@Environment(Type.self)` when the model conforms to `@Observable` (iOS 17+). ARC Labs code targeting iOS 17+ **must not** use `@EnvironmentObject`. The `@Observable` macro provides the same propagation mechanism with better performance and compile-time safety. + +--- + ### Feature-Specific Router (for complex features) ```swift @@ -774,19 +896,19 @@ Button("Load Profile") { } // 2. ViewModel Coordinates -@MainActor @Observable final class ProfileViewModel { + @MainActor func onTappedLoadProfile() async { isLoading = true - + do { // Call Use Case user = try await getUserProfileUseCase.execute(userId: currentUserId) } catch { errorMessage = error.localizedDescription } - + isLoading = false } } @@ -845,7 +967,7 @@ final class UserRepositoryImpl: UserRepositoryProtocol { ### ViewModels - [ ] State is `private(set)` - [ ] Dependencies injected via init -- [ ] Uses `@Observable` and `@MainActor` +- [ ] Uses `@Observable`; `@MainActor` on specific methods only - [ ] User actions prefixed with "on" - [ ] Calls Use Cases (not Repositories directly) - [ ] Tells Router to navigate (doesn't navigate itself)