TCA + SPM Modular Architecture
Project Layout
MyApp/
├── MyApp/ # Xcode app target — THIN HOST ONLY (~16 lines)
│ └── MyApp.swift # @main — creates one Store, renders root view
└── MyAppKit/ # Single SPM package containing ALL code
├── Package.swift
├── Sources/
│ ├── AppCore/ # Root reducer + root view, composes all tab features
│ ├── DesignSystem/ # Tokens, colors, fonts — no TCA dependency
│ ├── Models/ # Pure Swift value types — no TCA, no UI
│ ├── Services/ # Dependency clients + actors — no UI
│ ├── <SharedComponent>/ # Reusable TCA reducer used by 2+ features (Quiz, ChapterStep…)
│ └── <Feature>/ # One module per tab/top-level feature
│ └── Resources/ # JSON, images owned by this module (.process("Resources"))
└── Tests/
└── <Feature>Tests/
Rules:
- The Xcode target never contains business logic. All code lives in the SPM package.
- No file header comments — files start directly with
import statements.
- Each module that owns static resources (
Resources/) must expose a public <Module>Bundle.swift with public static let bundle = Bundle.module so sibling modules can access those resources without a circular dependency.
Workflow
- Decompose features → Read
references/module-design.md
- Set up Package.swift → Read
references/module-design.md (Package.swift section)
- Implement reducers/views/navigation → Read
references/tca-patterns.md
- Define dependency clients → Read
references/dependency-patterns.md
- Wire root AppCore → See App Entry Point below
App Entry Point
// MyApp/MyAppApp.swift
import ComposableArchitecture
import AppCore
import SwiftUI
@main
struct MyApp: App {
let store = Store(initialState: AppCoreReducer.State()) {
AppCoreReducer()
}
var body: some Scene {
WindowGroup { AppCoreView(store: store) }
}
}
AppCore (Root Reducer + TabView)
AppCore imports every tab feature and composes them with Scope. The Reduce block at the end handles cross-feature logic by intercepting child delegate actions.
// Sources/AppCore/AppCoreView.swift
@Reducer
public struct AppCoreReducer {
public enum Tab: Hashable { case home, profile, settings }
@ObservableState
public struct State: Equatable {
var selectedTab: Tab = .home
var home = HomeReducer.State()
var profile = ProfileReducer.State()
var settings = SettingsReducer.State()
public init() {}
}
public enum Action {
case selectedTabChanged(Tab)
case home(HomeReducer.Action)
case profile(ProfileReducer.Action)
case settings(SettingsReducer.Action)
}
public var body: some ReducerOf<Self> {
Scope(state: \.home, action: \.home) { HomeReducer() }
Scope(state: \.profile, action: \.profile) { ProfileReducer() }
Scope(state: \.settings, action: \.settings) { SettingsReducer() }
Reduce { state, action in
switch action {
case .selectedTabChanged(let tab):
state.selectedTab = tab; return .none
case .settings(.delegate(.loggedOut)):
state.selectedTab = .home; return .none
case .home, .profile, .settings:
return .none
}
}
}
}
public struct AppCoreView: View {
@Bindable var store: StoreOf<AppCoreReducer>
public var body: some View {
TabView(selection: $store.selectedTab.sending(\.selectedTabChanged)) {
NavigationStack {
HomeView(store: store.scope(state: \.home, action: \.home))
}
.tabItem { Label("Home", systemImage: "house") }
.tag(AppCoreReducer.Tab.home)
// … other tabs
}
}
}
Key Principles
@ObservableState on every State — enables direct store.property reads in SwiftUI
@Bindable var store in views — enables two-way $store.field bindings
Scope before Reduce — child reducers always run before parent logic
- Delegate actions for child→parent communication — see
references/tca-patterns.md
@Reducer enum Destination for push/sheet/alert navigation — see references/tca-patterns.md
- Dual
@Presents for overlay + navigation simultaneously — see references/tca-patterns.md
@DependencyClient or manual struct for services — see references/dependency-patterns.md
- Shared Component modules for reusable TCA reducers (used by 2+ features) — see
references/module-design.md
- Bundle parameter when a child reducer loads resources from a parent module's
Resources/ — see references/tca-patterns.md
Non-Negotiable Defaults
UI: Vanilla SwiftUI always. Use UIViewRepresentable only when SwiftUI has no equivalent (e.g. MKMapView, WKWebView, MTKView). Never wrap a UIKit component just for styling convenience.
Concurrency (in priority order):
async/await + AsyncStream + Swift actors — always try this first
- Combine — only if the API you're wrapping only exposes a
Publisher and no async equivalent exists
- GCD /
NSLock / DispatchQueue — only as a last resort for legacy C/ObjC callback-only APIs
Reference Files
references/module-design.md — Module decomposition guide, Package.swift templates, dependency graph rules, shared bundle access pattern
references/tca-patterns.md — Full reducer pattern, navigation (Destination enum + @Presents), delegate actions, async effects, testing
references/dependency-patterns.md — @DependencyClient macro pattern, manual struct pattern, actor-as-dependency, live/test/mock implementations
1---2name: tca-architect3description: Architect modular iOS apps using Swift Package Manager and The Composable Architecture (TCA). Use when designing or implementing a new iOS app (or feature module) that should be split into separate SPM packages, each owning a TCA feature reducer, view, and tests. Covers the full workflow: module decomposition, Package.swift dependency graph, reducer/view/navigation patterns, dependency injection via swift-dependencies, and the delegate action pattern for cross-module communication. Trigger when the user asks to: architect a TCA app, create a new SPM module/feature, set up a modular iOS project, add a feature to an existing TCA app, wire navigation between features, or design a dependency client.4---56# TCA + SPM Modular Architecture78## Project Layout910```11MyApp/12├── MyApp/ # Xcode app target — THIN HOST ONLY (~16 lines)13│ └── MyApp.swift # @main — creates one Store, renders root view14└── MyAppKit/ # Single SPM package containing ALL code15 ├── Package.swift16 ├── Sources/17 │ ├── AppCore/ # Root reducer + root view, composes all tab features18 │ ├── DesignSystem/ # Tokens, colors, fonts — no TCA dependency19 │ ├── Models/ # Pure Swift value types — no TCA, no UI20 │ ├── Services/ # Dependency clients + actors — no UI21 │ ├── <SharedComponent>/ # Reusable TCA reducer used by 2+ features (Quiz, ChapterStep…)22 │ └── <Feature>/ # One module per tab/top-level feature23 │ └── Resources/ # JSON, images owned by this module (.process("Resources"))24 └── Tests/25 └── <Feature>Tests/26```2728**Rules:**29- The Xcode target never contains business logic. All code lives in the SPM package.30- No file header comments — files start directly with `import` statements.31- Each module that owns static resources (`Resources/`) must expose a public `<Module>Bundle.swift` with `public static let bundle = Bundle.module` so sibling modules can access those resources without a circular dependency.3233## Workflow34351. **Decompose features** → Read `references/module-design.md`362. **Set up Package.swift** → Read `references/module-design.md` (Package.swift section)373. **Implement reducers/views/navigation** → Read `references/tca-patterns.md`384. **Define dependency clients** → Read `references/dependency-patterns.md`395. **Wire root AppCore** → See App Entry Point below4041## App Entry Point4243```swift44// MyApp/MyAppApp.swift45import ComposableArchitecture46import AppCore47import SwiftUI4849@main50struct MyApp: App {51 let store = Store(initialState: AppCoreReducer.State()) {52 AppCoreReducer()53 }54 var body: some Scene {55 WindowGroup { AppCoreView(store: store) }56 }57}58```5960## AppCore (Root Reducer + TabView)6162`AppCore` imports every tab feature and composes them with `Scope`. The `Reduce` block at the end handles cross-feature logic by intercepting child delegate actions.6364```swift65// Sources/AppCore/AppCoreView.swift66@Reducer67public struct AppCoreReducer {68 public enum Tab: Hashable { case home, profile, settings }6970 @ObservableState71 public struct State: Equatable {72 var selectedTab: Tab = .home73 var home = HomeReducer.State()74 var profile = ProfileReducer.State()75 var settings = SettingsReducer.State()76 public init() {}77 }7879 public enum Action {80 case selectedTabChanged(Tab)81 case home(HomeReducer.Action)82 case profile(ProfileReducer.Action)83 case settings(SettingsReducer.Action)84 }8586 public var body: some ReducerOf<Self> {87 Scope(state: \.home, action: \.home) { HomeReducer() }88 Scope(state: \.profile, action: \.profile) { ProfileReducer() }89 Scope(state: \.settings, action: \.settings) { SettingsReducer() }9091 Reduce { state, action in92 switch action {93 case .selectedTabChanged(let tab):94 state.selectedTab = tab; return .none95 case .settings(.delegate(.loggedOut)):96 state.selectedTab = .home; return .none97 case .home, .profile, .settings:98 return .none99 }100 }101 }102}103104public struct AppCoreView: View {105 @Bindable var store: StoreOf<AppCoreReducer>106 public var body: some View {107 TabView(selection: $store.selectedTab.sending(\.selectedTabChanged)) {108 NavigationStack {109 HomeView(store: store.scope(state: \.home, action: \.home))110 }111 .tabItem { Label("Home", systemImage: "house") }112 .tag(AppCoreReducer.Tab.home)113 // … other tabs114 }115 }116}117```118119## Key Principles120121- **`@ObservableState`** on every `State` — enables direct `store.property` reads in SwiftUI122- **`@Bindable var store`** in views — enables two-way `$store.field` bindings123- **`Scope` before `Reduce`** — child reducers always run before parent logic124- **Delegate actions** for child→parent communication — see `references/tca-patterns.md`125- **`@Reducer enum Destination`** for push/sheet/alert navigation — see `references/tca-patterns.md`126- **Dual `@Presents`** for overlay + navigation simultaneously — see `references/tca-patterns.md`127- **`@DependencyClient` or manual struct** for services — see `references/dependency-patterns.md`128- **Shared Component modules** for reusable TCA reducers (used by 2+ features) — see `references/module-design.md`129- **Bundle parameter** when a child reducer loads resources from a parent module's `Resources/` — see `references/tca-patterns.md`130131## Non-Negotiable Defaults132133**UI:** Vanilla SwiftUI always. Use `UIViewRepresentable` only when SwiftUI has no equivalent (e.g. `MKMapView`, `WKWebView`, `MTKView`). Never wrap a UIKit component just for styling convenience.134135**Concurrency (in priority order):**1361. `async/await` + `AsyncStream` + Swift actors — always try this first1372. Combine — only if the API you're wrapping only exposes a `Publisher` and no async equivalent exists1383. GCD / `NSLock` / `DispatchQueue` — only as a last resort for legacy C/ObjC callback-only APIs139140## Reference Files141142- **`references/module-design.md`** — Module decomposition guide, Package.swift templates, dependency graph rules, shared bundle access pattern143- **`references/tca-patterns.md`** — Full reducer pattern, navigation (Destination enum + @Presents), delegate actions, async effects, testing144- **`references/dependency-patterns.md`** — `@DependencyClient` macro pattern, manual struct pattern, actor-as-dependency, live/test/mock implementations