Instruction file imported from sethdford/lag-secret-assassin-ios (
.cursor/rules/tca-migration-strategy.mdc). Copyright stays with the author.
TCA Migration Strategy for Assassin App
Comprehensive migration plan from MVVM to The Composable Architecture, following isowords production patterns.
Migration Phases
Phase 1: Foundation Setup
1.1 Add TCA Dependencies
// Package.swift additions
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.0.0")
1.2 Create Core TCA Infrastructure
// ✅ DO: Create AppState (root state)
struct AppState: Equatable {
var authentication: AuthenticationState = .init()
var game: GameState?
var profile: ProfileState = .init()
var onboarding: OnboardingState?
var route: Route?
enum Route: Equatable {
case game(GameState)
case settings(SettingsState)
case profile(ProfileState)
}
}
// ✅ DO: Create AppAction (root actions)
enum AppAction: Equatable {
case appDelegate(AppDelegateAction)
case authentication(AuthenticationAction)
case game(GameAction)
case profile(ProfileAction)
case onboarding(OnboardingAction)
case route(RouteAction)
case didChangeScenePhase(ScenePhase)
}
// ✅ DO: Create AppEnvironment (root dependencies)
struct AppEnvironment {
var authenticationClient: AuthenticationClient
var gameClient: GameClient
var locationClient: LocationClient
var notificationClient: NotificationClient
var databaseClient: DatabaseClient
var mainQueue: AnySchedulerOf<DispatchQueue>
var backgroundQueue: AnySchedulerOf<DispatchQueue>
var date: () -> Date
var uuid: () -> UUID
}
1.3 Create Client Protocols
// ✅ DO: Convert existing services to TCA clients
struct AuthenticationClient {
var signIn: (String, String) -> Effect<User, AuthenticationError>
var signOut: () -> Effect<Never, Never>
var getCurrentUser: () -> Effect<User?, Never>
var refreshToken: () -> Effect<String, AuthenticationError>
}
struct GameClient {
var createGame: (CreateGameRequest) -> Effect<Game, GameError>
var joinGame: (String) -> Effect<Game, GameError>
var leaveGame: (Game.ID) -> Effect<Never, GameError>
var loadActiveGames: () -> Effect<[Game], GameError>
var updateGameStatus: (Game.ID, GameStatus) -> Effect<Game, GameError>
}
struct LocationClient {
var requestPermission: () -> Effect<CLAuthorizationStatus, Never>
var startLocationUpdates: () -> Effect<CLLocation, LocationError>
var stopLocationUpdates: () -> Effect<Never, Never>
var getCurrentLocation: () -> Effect<CLLocation, LocationError>
}
Phase 2: Feature-by-Feature Migration
2.1 Start with Leaf Features (No Dependencies)
Migrate features that don't depend on other features first:
// ✅ DO: Migrate Settings feature first
struct SettingsState: Equatable {
var notificationsEnabled: Bool = true
var locationSharingEnabled: Bool = true
var biometricAuthEnabled: Bool = false
var theme: AppTheme = .system
var isLoading: Bool = false
var alert: AlertState<SettingsAction>?
}
enum SettingsAction: Equatable {
case onAppear
case notificationsToggled(Bool)
case locationSharingToggled(Bool)
case biometricAuthToggled(Bool)
case themeChanged(AppTheme)
case saveSettings
case settingsSaved(Result<Void, SettingsError>)
case alertDismissed
}
let settingsReducer = Reducer<SettingsState, SettingsAction, SettingsEnvironment> { state, action, environment in
switch action {
case .onAppear:
return environment.settingsClient
.loadSettings()
.receive(on: environment.mainQueue)
.catchToEffect(SettingsAction.settingsLoaded)
case let .notificationsToggled(enabled):
state.notificationsEnabled = enabled
return Effect(value: .saveSettings)
// ... other cases
}
}
2.2 Migrate Core Features
// ✅ DO: Migrate Authentication feature
struct AuthenticationState: Equatable {
var currentUser: User?
var isSigningIn: Bool = false
var isSigningOut: Bool = false
var signInForm: SignInFormState = .init()
var alert: AlertState<AuthenticationAction>?
var isAuthenticated: Bool {
currentUser != nil
}
}
enum AuthenticationAction: Equatable {
case onAppear
case signInFormChanged(SignInFormAction)
case signInButtonTapped
case signOutButtonTapped
case signInResponse(Result<User, AuthenticationError>)
case signOutResponse(Result<Never, Never>)
case alertDismissed
}
let authenticationReducer = Reducer<AuthenticationState, AuthenticationAction, AuthenticationEnvironment>
.combine(
signInFormReducer.pullback(
state: \.signInForm,
action: /AuthenticationAction.signInFormChanged,
environment: { _ in SignInFormEnvironment() }
),
authenticationCore
)
2.3 Migrate Complex Features with Dependencies
// ✅ DO: Migrate Game feature (depends on Authentication, Location)
struct GameState: Equatable {
var currentGame: Game?
var availableGames: IdentifiedArrayOf<Game> = []
var players: IdentifiedArrayOf<Player> = []
var isLoading: Bool = false
var route: Route?
enum Route: Equatable {
case createGame(CreateGameState)
case gameDetail(GameDetailState)
case playerProfile(PlayerProfileState)
}
}
enum GameAction: Equatable {
case onAppear
case refreshGames
case createGameButtonTapped
case joinGameButtonTapped(Game.ID)
case gameSelected(Game.ID)
case gamesLoaded(Result<[Game], GameError>)
case route(RouteAction)
enum RouteAction: Equatable {
case createGame(CreateGameAction)
case gameDetail(GameDetailAction)
case playerProfile(PlayerProfileAction)
case dismiss
}
}
Phase 3: Integration and Composition
3.1 Create Root Reducer
// ✅ DO: Compose all feature reducers
let appReducer = Reducer<AppState, AppAction, AppEnvironment>
.combine(
authenticationReducer.pullback(
state: \.authentication,
action: /AppAction.authentication,
environment: { env in
AuthenticationEnvironment(
authenticationClient: env.authenticationClient,
mainQueue: env.mainQueue
)
}
),
gameReducer.optional().pullback(
state: \.game,
action: /AppAction.game,
environment: { env in
GameEnvironment(
gameClient: env.gameClient,
locationClient: env.locationClient,
mainQueue: env.mainQueue,
backgroundQueue: env.backgroundQueue
)
}
),
profileReducer.pullback(
state: \.profile,
action: /AppAction.profile,
environment: { env in
ProfileEnvironment(
userClient: env.userClient,
mainQueue: env.mainQueue
)
}
),
appReducerCore
)
private let appReducerCore = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
switch action {
case .appDelegate(.didFinishLaunching):
// Initialize app
return .merge(
Effect(value: .authentication(.checkAuthenticationStatus)),
environment.notificationClient
.requestPermission()
.fireAndForget()
)
case let .didChangeScenePhase(phase):
switch phase {
case .active:
return Effect(value: .authentication(.refreshTokenIfNeeded))
case .background:
return environment.databaseClient
.saveContext()
.fireAndForget()
default:
return .none
}
// Handle cross-feature coordination
case .authentication(.signInResponse(.success(let user))):
// User signed in, load their games
return Effect(value: .game(.refreshGames))
case .authentication(.signOutResponse):
// User signed out, clear game state
state.game = nil
return .none
}
}
3.2 Update App Entry Point
// ✅ DO: Update App.swift for TCA
final class AppDelegate: NSObject, UIApplicationDelegate {
let store = Store(
initialState: AppState(),
reducer: appReducer,
environment: .live
)
lazy var viewStore = ViewStore(
store.scope(state: { _ in () }),
removeDuplicates: ==
)
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
viewStore.send(.appDelegate(.didFinishLaunching))
return true
}
}
@main
struct AssassinApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self)
private var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
AppView(store: appDelegate.store)
}
.onChange(of: scenePhase) { phase in
appDelegate.viewStore.send(.didChangeScenePhase(phase))
}
}
}
Phase 4: View Migration
4.1 Convert ViewModels to TCA Views
// ❌ BEFORE: MVVM View
struct GameListView: View {
@StateObject private var viewModel = GameListViewModel()
var body: some View {
// MVVM implementation
}
}
// ✅ AFTER: TCA View
struct GameListView: View {
let store: Store<GameListState, GameListAction>
var body: some View {
WithViewStore(store) { viewStore in
NavigationView {
List {
ForEach(viewStore.games) { game in
GameRowView(game: game) {
viewStore.send(.gameSelected(game.id))
}
}
}
.navigationTitle("Games")
.onAppear {
viewStore.send(.onAppear)
}
.refreshable {
await viewStore.send(.refreshGames, while: \.isLoading)
}
}
}
}
}
4.2 Handle Navigation
// ✅ DO: TCA Navigation patterns
struct AppView: View {
let store: Store<AppState, AppAction>
var body: some View {
WithViewStore(store) { viewStore in
Group {
if viewStore.authentication.isAuthenticated {
if let gameState = viewStore.game {
GameView(
store: store.scope(
state: \.game!,
action: AppAction.game
)
)
} else {
MainTabView(store: store)
}
} else {
AuthenticationView(
store: store.scope(
state: \.authentication,
action: AppAction.authentication
)
)
}
}
.sheet(
isPresented: viewStore.binding(
get: { $0.onboarding != nil },
send: { _ in .onboarding(.dismiss) }
)
) {
IfLetStore(
store.scope(
state: \.onboarding,
action: AppAction.onboarding
)
) { onboardingStore in
OnboardingView(store: onboardingStore)
}
}
}
}
}
Phase 5: Testing Migration
5.1 Convert ViewModel Tests to Reducer Tests
// ❌ BEFORE: ViewModel test
final class GameListViewModelTests: XCTestCase {
func testLoadGames() async {
let viewModel = GameListViewModel(gameService: MockGameService())
await viewModel.loadGames()
XCTAssertFalse(viewModel.games.isEmpty)
}
}
// ✅ AFTER: TCA Reducer test
final class GameListReducerTests: XCTestCase {
func testLoadGames() {
let games = [Game.mock1, Game.mock2]
let scheduler = DispatchQueue.test
var environment = GameListEnvironment.failing
environment.gameClient.loadGames = {
Effect(value: games)
.delay(for: 1, scheduler: scheduler)
.eraseToEffect()
}
environment.mainQueue = scheduler.eraseToAnyScheduler()
let store = TestStore(
initialState: GameListState(),
reducer: gameListReducer,
environment: environment
)
store.send(.onAppear) {
$0.isLoading = true
}
scheduler.advance(by: 1)
store.receive(.gamesLoaded(.success(games))) {
$0.isLoading = false
$0.games = IdentifiedArray(uniqueElements: games)
}
}
}
Migration Checklist
Pre-Migration
- Add TCA dependencies to Package.swift
- Create core TCA infrastructure (AppState, AppAction, AppEnvironment)
- Define client protocols for existing services
- Set up failing/mock/live environment variants
Feature Migration Order
- Settings (leaf feature, no dependencies)
- Profile (minimal dependencies)
- Authentication (core feature)
- Location Services (core feature)
- Game Management (complex feature with dependencies)
- Real-time Game Features (most complex)
Per-Feature Migration
- Define State struct with Equatable conformance
- Define Action enum with Equatable conformance
- Define Environment struct with dependencies
- Implement reducer with proper effect handling
- Convert views from @StateObject to Store
- Update navigation to use TCA patterns
- Migrate tests from ViewModel to Reducer tests
- Add integration tests for feature composition
Post-Migration
- Remove old MVVM ViewModels and ObservableObjects
- Update documentation and architecture guides
- Performance testing and optimization
- Add comprehensive integration tests
- Update CI/CD for TCA testing patterns
Common Migration Patterns
Converting @Published Properties
// ❌ BEFORE: ViewModel with @Published
class GameViewModel: ObservableObject {
@Published var games: [Game] = []
@Published var isLoading = false
@Published var errorMessage: String?
}
// ✅ AFTER: TCA State
struct GameState: Equatable {
var games: IdentifiedArrayOf<Game> = []
var isLoading = false
var alert: AlertState<GameAction>?
}
Converting Async Methods
// ❌ BEFORE: ViewModel async method
func loadGames() async {
isLoading = true
do {
games = try await gameService.fetchGames()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
// ✅ AFTER: TCA Effect
case .loadGames:
state.isLoading = true
return environment.gameClient
.loadGames()
.receive(on: environment.mainQueue)
.catchToEffect(GameAction.gamesLoaded)
case let .gamesLoaded(.success(games)):
state.isLoading = false
state.games = IdentifiedArray(uniqueElements: games)
return .none
case let .gamesLoaded(.failure(error)):
state.isLoading = false
state.alert = AlertState(title: TextState("Error"), message: TextState(error.localizedDescription))
return .none
Converting Navigation
// ❌ BEFORE: NavigationLink with @State
@State private var selectedGame: Game?
NavigationLink(
destination: GameDetailView(game: selectedGame!),
isActive: .constant(selectedGame != nil)
) {
Text("View Game")
}
// ✅ AFTER: TCA Navigation
// In State:
var route: Route?
enum Route: Equatable {
case gameDetail(GameDetailState)
}
// In View:
.sheet(
isPresented: viewStore.binding(
get: { $0.route?.gameDetail != nil },
send: { _ in .route(.dismiss) }
)
) {
IfLetStore(
store.scope(
state: \.route?.gameDetail,
action: GameAction.route .. RouteAction.gameDetail
)
) { gameDetailStore in
GameDetailView(store: gameDetailStore)
}
}