| name | axiom-app-composition |
| description | Use when structuring app entry points, managing authentication flows, switching root views, handling scene lifecycle, or asking 'how do I structure my @main', 'where does auth state live', 'how do I prevent screen flicker on launch', 'when should I modularize' - app-level composition patterns for iOS 26+ |
| license | MIT |
| compatibility | iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+. Xcode 26+ |
| metadata | {"version":"1.0"} |
App Composition
When to Use This Skill
Use this skill when:
- Structuring your @main entry point and root view
- Managing authentication state (login → onboarding → main)
- Switching between app-level states without flicker
- Handling scene lifecycle events (scenePhase)
- Restoring app state after termination
- Deciding when to split into feature modules
- Coordinating between multiple windows (iPad, axiom-visionOS)
Example Prompts
| What You Might Ask | Why This Skill Helps |
|---|
| "How do I switch between login and main screens?" | AppStateController pattern with validated transitions |
| "My app flickers when switching from splash to main" | Flicker prevention with animation coordination |
| "Where should auth state live?" | App-level state machine, not scattered booleans |
| "How do I handle app going to background?" | scenePhase lifecycle patterns |
| "When should I split my app into modules?" | Decision tree based on codebase size and team |
| "How do I restore state after app is killed?" | SceneStorage and state validation patterns |
Quick Decision Tree
What app-level architecture question are you solving?
│
├─ How do I manage app states (loading, auth, main)?
│ └─ Part 1: App-Level State Machines
│ - Enum-based state with validated transitions
│ - AppStateController pattern
│ - Prevents "boolean soup" anti-pattern
│
├─ How do I structure @main and root view switching?
│ └─ Part 2: Root View Switching Patterns
│ - Delegate to AppStateController (no logic in @main)
│ - Flicker prevention with animation
│ - Coordinator integration
│
├─ How do I handle scene lifecycle?
│ └─ Part 3: Scene Lifecycle Integration
│ - scenePhase for session validation
│ - SceneStorage for restoration
│ - Multi-window coordination
│
├─ When should I modularize?
│ └─ Part 4: Feature Module Basics
│ - Decision tree by size/team
│ - Module boundaries and DI
│ - Navigation coordination
│
└─ What mistakes should I avoid?
└─ Part 5: Anti-Patterns + Part 6: Pressure Scenarios
- Boolean-based state
- Logic in @main
- Missing restoration validation
Part 1: App-Level State Machines
Core Principle
"Apps have discrete states. Model them explicitly with enums, not scattered booleans."
Every non-trivial app has distinct states: loading, unauthenticated, onboarding, authenticated, error recovery. These states should be:
- Explicit — An enum, not multiple booleans
- Validated — Transitions are checked and logged
- Centralized — One source of truth
- Observable — Views react to state changes
The Boolean Soup Problem
class AppState {
var isLoading = true
var isLoggedIn = false
var hasCompletedOnboarding = false
var hasError = false
var user: User?
}
Problems
- No compile-time guarantee of valid states
- Easy to forget to update one boolean
- Testing requires checking all combinations
- Race conditions create impossible states
The AppStateController Pattern
Step 1: Define Explicit States
enum AppState: Equatable {
case loading
case unauthenticated
case onboarding(OnboardingStep)
case authenticated(User)
case error(AppError)
}
enum OnboardingStep: Equatable {
case welcome
case permissions
case profileSetup
case complete
}
enum AppError: Equatable {
case networkUnavailable
case sessionExpired
case maintenanceMode
}
Step 2: Create the Controller
@Observable
@MainActor
class AppStateController {
private(set) var state: AppState = .loading
func transition(to newState: AppState) {
guard isValidTransition(from: state, to: newState) else {
assertionFailure("Invalid transition: \(state) → \(newState)")
logInvalidTransition(from: state, to: newState)
return
}
let oldState = state
state = newState
logTransition(from: oldState, to: newState)
}
private func isValidTransition(from: AppState, to: AppState) -> Bool {
switch (from, to) {
case (.loading, .unauthenticated): return true
case (.loading, .authenticated): return true
case (.loading, .error): return true
case (.unauthenticated, .onboarding): return true
case (.unauthenticated, .authenticated): return true
case (.unauthenticated, .error): return true
case (.onboarding, .onboarding): return true
case (.onboarding, .authenticated): return true
case (.onboarding, .unauthenticated): return true
case (.authenticated, .unauthenticated): return true
case (.authenticated, .error): return true
case (.error, .loading): return true
case (.error, .unauthenticated): return true
default: return false
}
}
private func logTransition(from: AppState, to: AppState) {
#if DEBUG
print("AppState: \(from) → \(to)")
#endif
}
private func logInvalidTransition(from: AppState, to: AppState) {
Analytics.log("InvalidStateTransition", properties: [
"from": String(describing: from),
"to": String(describing: to)
])
}
}
Step 3: Initialize from Storage
extension AppStateController {
func initialize() async {
if let session = await SessionStorage.loadSession() {
do {
let user = try await AuthService.validateSession(session)
transition(to: .authenticated(user))
} catch {
await SessionStorage.clearSession()
transition(to: .unauthenticated)
}
} else {
transition(to: .unauthenticated)
}
}
}
State Machine Diagram
┌─────────────────────────────────────────────────────────────┐
│ .loading │
└────────────┬───────────────┬────────────────┬───────────────┘
│ │ │
▼ ▼ ▼
.unauthenticated .authenticated .error
│ │ │
▼ │ │
.onboarding ─────────►│◄───────────────┘
│ │
└───────────────┘
Testing State Machines
@Test func testValidTransitions() async {
let controller = AppStateController()
controller.transition(to: .unauthenticated)
#expect(controller.state == .unauthenticated)
let user = User(id: "1", name: "Test")
controller.transition(to: .authenticated(user))
#expect(controller.state == .authenticated(user))
}
@Test func testInvalidTransitionRejected() async {
let controller = AppStateController()
controller.transition(to: .onboarding(.welcome))
#expect(controller.state == .loading)
}
@Test func testSessionExpiredTransition() async {
let controller = AppStateController()
let user = User(id: "1", name: "Test")
controller.transition(to: .authenticated(user))
controller.transition(to: .error(.sessionExpired))
#expect(controller.state == .error(.sessionExpired))
controller.transition(to: .unauthenticated)
#expect(controller.state == .unauthenticated)
}
The State-as-Bridge Pattern (WWDC 2025/266)
From WWDC 2025's "Explore concurrency in SwiftUI":
"Find the boundaries between UI code that requires time-sensitive changes, and long-running async logic."
The key insight: synchronous state changes drive UI (for animations), async code lives in the model (testable without SwiftUI), and state bridges the two.
struct ColorExtractorView: View {
@State private var model = ColorExtractor()
var body: some View {
Button("Extract Colors") {
withAnimation { model.isExtracting = true }
Task {
await model.extractColors()
withAnimation { model.isExtracting = false }
}
}
.scaleEffect(model.isExtracting ? 1.5 : 1.0)
}
}
@Observable
class ColorExtractor {
var isExtracting = false
var colors: [Color] = []
func extractColors() async {
let extracted = await heavyComputation()
colors = extracted
}
}
Why this matters for app composition
- App-level state changes (loading → authenticated) should be synchronous
- Heavy work (session validation, data loading) should be async in the model
- This separation makes state machines testable without SwiftUI imports
Part 2: Root View Switching Patterns
Core Principle
"The @main entry point should be a thin shell. All logic belongs in AppStateController."
The Clean @main Pattern
@main
struct MyApp: App {
@State private var appState = AppStateController()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task {
await appState.initialize()
}
}
}
}
What @main does
- Creates AppStateController
- Injects it via environment
- Triggers initialization
What @main does NOT do
- Business logic
- Auth checks
- Conditional rendering
- Navigation decisions
RootView: The State Switch
struct RootView: View {
@Environment(AppStateController.self) private var appState
var body: some View {
Group {
switch appState.state {
case .loading:
LaunchView()
case .unauthenticated:
AuthenticationFlow()
case .onboarding(let step):
OnboardingFlow(step: step)
case .authenticated(let user):
MainTabView(user: user)
case .error(let error):
ErrorRecoveryView(error: error)
}
}
}
}
Preventing Flicker During Transitions
Problem: Flash of Wrong Content
When app state changes, you might see a flash of the old screen before the new one appears. This happens when:
- State changes before view is ready
- No transition animation
- Loading state too short to perceive
Solution: Animated Transitions
struct RootView: View {
@Environment(AppStateController.self) private var appState
var body: some View {
ZStack {
switch appState.state {
case .loading:
LaunchView()
.transition(.opacity)
case .unauthenticated:
AuthenticationFlow()
.transition(.opacity)
case .onboarding(let step):
OnboardingFlow(step: step)
.transition(.opacity)
case .authenticated(let user):
MainTabView(user: user)
.transition(.opacity)
case .error(let error):
ErrorRecoveryView(error: error)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.3), value: appState.state)
}
}
Minimum Loading Duration
For a polished experience, ensure the loading screen is visible long enough:
extension AppStateController {
func initialize() async {
let startTime = Date()
await performInitialization()
let elapsed = Date().timeIntervalSince(startTime)
let minimumDuration: TimeInterval = 0.5
if elapsed < minimumDuration {
try? await Task.sleep(for: .seconds(minimumDuration - elapsed))
}
}
}
Coordinator Integration
If using coordinators, integrate them at the root level:
struct RootView: View {
@Environment(AppStateController.self) private var appState
@State private var authCoordinator = AuthCoordinator()
@State private var mainCoordinator = MainCoordinator()
var body: some View {
Group {
switch appState.state {
case .loading:
LaunchView()
case .unauthenticated, .onboarding:
AuthenticationFlow()
.environment(authCoordinator)
case .authenticated(let user):
MainTabView(user: user)
.environment(mainCoordinator)
case .error(let error):
ErrorRecoveryView(error: error)
}
}
.animation(.easeInOut(duration: 0.3), value: appState.state)
}
}
Part 3: Scene Lifecycle Integration
Core Principle
"Scene lifecycle events are app-wide concerns handled centrally, not scattered across features."
Understanding ScenePhase (Apple Documentation)
ScenePhase indicates a scene's operational state. How you interpret the value depends on where it's read.
Read from a View → Returns the phase of the enclosing scene
Read from App → Returns an aggregate value reflecting all scenes
| Phase | Description |
|---|
.active | Scene is in the foreground and interactive |
.inactive | Scene is in the foreground but should pause work |
.background | Scene isn't visible; app may terminate soon |
Critical insight from Apple docs When reading at the App level, .active means any scene is active, and .background means all scenes are in background.
scenePhase Handling
@main
struct MyApp: App {
@State private var appState = AppStateController()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task {
await appState.initialize()
}
}
.onChange(of: scenePhase) { oldPhase, newPhase in
handleScenePhaseChange(from: oldPhase, to: newPhase)
}
}
private func handleScenePhaseChange(from: ScenePhase, to: ScenePhase) {
switch to {
case .active:
Task {
await appState.validateSession()
await appState.refreshIfNeeded()
}
case .inactive:
appState.prepareForBackground()
case .background:
appState.releaseResources()
@unknown default:
break
}
}
}
Session Validation on Active
extension AppStateController {
func validateSession() async {
guard case .authenticated(let user) = state else { return }
do {
let isValid = try await AuthService.validateToken(user.token)
if !isValid {
transition(to: .error(.sessionExpired))
}
} catch {
}
}
func prepareForBackground() {
}
func releaseResources() {
}
}
SceneStorage for State Restoration
From Apple documentation: SceneStorage provides automatic state restoration. The system manages saving and restoring on your behalf.
Key constraints
- Keep data lightweight (not full models)
- Each Scene has its own storage (not shared)
- Data destroyed when scene is explicitly destroyed
struct MainTabView: View {
@SceneStorage("selectedTab") private var selectedTab = 0
@SceneStorage("lastViewedItemID") private var lastViewedItemID: String?
var body: some View {
TabView(selection: $selectedTab) {
HomeTab()
.tag(0)
SearchTab()
.tag(1)
ProfileTab()
.tag(2)
}
.onAppear {
if let itemID = lastViewedItemID {
navigateToItem(itemID)
}
}
}
}
Navigation State Restoration (WWDC 2022/10054)
For complex navigation, use a Codable NavigationModel:
class NavigationModel: ObservableObject, Codable {
@Published var selectedCategory: Category?
@Published var recipePath: [Recipe] = []
enum CodingKeys: String, CodingKey {
case selectedCategory
case recipePathIds
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
try container.encode(recipePath.map(\.id), forKey: .recipePathIds)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.selectedCategory = try container.decodeIfPresent(
Category.self, forKey: .selectedCategory)
let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] }
}
var jsonData: Data? {
get { try? JSONEncoder().encode(self) }
set {
guard let data = newValue,
let model = try? JSONDecoder().decode(NavigationModel.self, from: data)
else { return }
self.selectedCategory = model.selectedCategory
self.recipePath = model.recipePath
}
}
}
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
var body: some View {
NavigationSplitView { }
.task {
if let data = data {
navModel.jsonData = data
}
for await _ in navModel.objectWillChangeSequence {
data = navModel.jsonData
}
}
}
}
Key patterns from WWDC
- Store IDs only, not full model objects
- Use
compactMap to handle deleted items gracefully
- Save on every
objectWillChange for real-time persistence
Validating Restored State
Never trust restored state blindly:
struct DetailView: View {
@SceneStorage("detailItemID") private var restoredItemID: String?
@State private var item: Item?
var body: some View {
Group {
if let item {
ItemContent(item: item)
} else {
ProgressView()
}
}
.task {
if let itemID = restoredItemID {
item = await ItemService.fetch(itemID)
if item == nil {
restoredItemID = nil
}
}
}
}
}
Multi-Window Coordination (iPad, axiom-visionOS)
From Apple documentation: Every window in a WindowGroup maintains independent state. The system allocates new storage for @State and @StateObject for each window.
@main
struct MyApp: App {
@State private var appState = AppStateController()
var body: some Scene {
WindowGroup {
MainView()
.environment(appState)
}
WindowGroup("Detail", id: "detail", for: Item.ID.self) { $itemID in
if let itemID {
DetailView(itemID: itemID)
.environment(appState)
}
}
#if os(visionOS)
ImmersiveSpace(id: "immersive") {
ImmersiveView()
.environment(appState)
}
#endif
}
}
Key behaviors from Apple docs
- If a window with the same value already exists, the system brings it to front instead of opening a new one
- SwiftUI persists the binding value for state restoration
- Use unique identifier strings for each window group
Opening Additional Windows
struct ItemRow: View {
let item: Item
@Environment(\.openWindow) private var openWindow
var body: some View {
Button(item.title) {
openWindow(id: "detail", value: item.id)
}
}
}
Dismissing Windows Programmatically
struct DetailView: View {
var itemID: Item.ID?
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
Button("Done") {
dismiss()
}
}
}
}
Part 4: Feature Module Basics
Core Principle
"Split into modules when features have clear boundaries. Not before."
Premature modularization creates overhead. Late modularization creates pain. Use this decision tree.
When to Modularize Decision Tree
Should I extract this feature into a module?
│
├─ Is the codebase under 5,000 lines with 1-2 developers?
│ └─ NO modularization needed yet
│ Single target is fine, revisit at 10,000 lines
│
├─ Is the codebase 5,000-20,000 lines with 3+ developers?
│ └─ CONSIDER modularization
│ Look for natural boundaries
│
├─ Is the codebase over 20,000 lines?
│ └─ MODULARIZE for build times
│ Parallel compilation essential
│
├─ Could this feature be used in multiple apps?
│ └─ EXTRACT to reusable module
│ Shared authentication, analytics, axiom-networking
│
├─ Do multiple developers work on this feature daily?
│ └─ EXTRACT for merge conflict reduction
│ Isolated codebases = parallel work
│
└─ Does the feature have clear input/output boundaries?
├─ YES → Good candidate for module
└─ NO → Refactor boundaries first, then extract
Module Boundary Pattern
Define a Public API
public protocol FeatureAPI {
@MainActor
func makeMainView() -> AnyView
@MainActor
func handleDeepLink(_ url: URL) -> Bool
}
public struct FeatureFactory {
public static func create(
analytics: AnalyticsProtocol,
networking: NetworkingProtocol
) -> FeatureAPI {
FeatureImplementation(
analytics: analytics,
networking: axiom-networking
)
}
}
Internal Implementation
internal class FeatureImplementation: FeatureAPI {
private let analytics: AnalyticsProtocol
private let networking: NetworkingProtocol
internal init(
analytics: AnalyticsProtocol,
networking: NetworkingProtocol
) {
self.analytics = analytics
self.networking = networking
}
@MainActor
public func makeMainView() -> AnyView {
AnyView(FeatureMainView(viewModel: makeViewModel()))
}
public func handleDeepLink(_ url: URL) -> Bool {
return false
}
private func makeViewModel() -> FeatureViewModel {
FeatureViewModel(analytics: analytics, axiom-networking: axiom-networking)
}
}
Use in Main App
@Observable
class AppDependencies {
let analytics: AnalyticsProtocol
let networking: NetworkingProtocol
lazy var profileFeature: FeatureAPI = {
ProfileFeatureFactory.create(
analytics: analytics,
networking: axiom-networking
)
}()
lazy var settingsFeature: FeatureAPI = {
SettingsFeatureFactory.create(
analytics: analytics,
networking: axiom-networking
)
}()
}
struct MainTabView: View {
@Environment(AppDependencies.self) private var dependencies
var body: some View {
TabView {
dependencies.profileFeature.makeMainView()
.tabItem { Label("Profile", systemImage: "person") }
dependencies.settingsFeature.makeMainView()
.tabItem { Label("Settings", systemImage: "gear") }
}
}
}
Navigation Coordination Between Modules
Features should not know about each other directly:
struct ProfileView: View {
func showSettings() {
NavigationLink(value: SettingsDestination())
}
}
struct ProfileView: View {
let onShowSettings: () -> Void
func showSettings() {
onShowSettings()
}
}
class MainCoordinator {
func showSettings(from profile: ProfileFeatureAPI) {
navigationPath.append(SettingsRoute())
}
}
Module Folder Structure
MyApp/
├── App/ # Main app target
│ ├── MyApp.swift # @main entry point
│ ├── AppDependencies.swift # Dependency container
│ ├── AppStateController.swift # App state machine
│ └── Coordinators/ # Navigation coordinators
│
├── Packages/
│ ├── Core/ # Shared utilities
│ │ ├── Networking/
│ │ ├── Analytics/
│ │ └── Design/ # Design system
│ │
│ ├── Features/ # Feature modules
│ │ ├── Profile/
│ │ ├── Settings/
│ │ └── Onboarding/
│ │
│ └── Domain/ # Business logic
│ ├── Models/
│ └── Services/
Part 5: Anti-Patterns
Anti-Pattern 1: Boolean-Based State
class AppState {
var isLoading = true
var isLoggedIn = false
var hasCompletedOnboarding = false
var hasError = false
}
Fix Use enum-based state (Part 1)
enum AppState {
case loading
case unauthenticated
case onboarding(OnboardingStep)
case authenticated(User)
case error(AppError)
}
Anti-Pattern 2: Logic in @main
@main
struct MyApp: App {
@State private var user: User?
@State private var isLoading = true
var body: some Scene {
WindowGroup {
if isLoading {
LoadingView()
} else if let user {
MainView(user: user)
} else {
LoginView(onLogin: { self.user = $0 })
}
}
.task {
user = await AuthService.getCurrentUser()
isLoading = false
}
}
}
Problems
- @main becomes bloated with logic
- Hard to test without launching app
- State scattered across multiple @State
Fix Delegate to AppStateController (Part 2)
@main
struct MyApp: App {
@State private var appState = AppStateController()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task { await appState.initialize() }
}
}
}
Anti-Pattern 3: Missing State Validation on Restore
.onAppear {
if let savedState = SceneStorage.appState {
appState.state = savedState
}
}
Problems
- Session could have expired
- User could have been logged out on another device
- Data could have been deleted
Fix Validate before applying (Part 3)
.task {
if let savedSession = await SessionStorage.loadSession() {
do {
let user = try await AuthService.validateSession(savedSession)
appState.transition(to: .authenticated(user))
} catch {
await SessionStorage.clearSession()
appState.transition(to: .unauthenticated)
}
}
}
Anti-Pattern 4: Navigation Logic Scattered Across Features
struct ProfileView: View {
@Environment(\.navigationPath) private var path
func showSettings() {
path.append(SettingsDestination())
}
func showOrderHistory() {
path.append(OrderHistoryDestination())
}
}
Problems
- Circular dependencies
- Hard to test navigation
- Changes ripple across modules
Fix Delegate to coordinator (Part 4)
struct ProfileView: View {
let onShowSettings: () -> Void
let onShowOrderHistory: () -> Void
}
Anti-Pattern 5: God Coordinator
class AppCoordinator {
func showProfile() { }
func showSettings() { }
func showOnboarding() { }
func showPayment() { }
func showChat() { }
func showOrderHistory() { }
func showNotifications() { }
}
Problems
- Massive file that everyone touches
- Merge conflicts
- Single point of failure
Fix Scoped coordinators
class AuthCoordinator { }
class MainCoordinator { }
class SettingsCoordinator { }
class OrderCoordinator { }
Part 5b: UIKit Integration (Incremental Adoption)
For comprehensive bridging patterns (UIViewRepresentable, UIViewControllerRepresentable, UIHostingConfiguration, coordinators, lifecycle, gotchas), see $axiom-uikit-bridging. This section covers app-level integration strategy only.
When This Applies
Most production iOS apps have existing UIKit code. Rewriting everything in SwiftUI is rarely practical. Use these patterns for incremental adoption.
UIHostingController — SwiftUI Inside UIKit
Embed SwiftUI views in an existing UIKit navigation hierarchy:
let settingsView = SettingsView(store: store)
let hostingController = UIHostingController(rootView: settingsView)
navigationController?.pushViewController(hostingController, animated: true)
Key rules:
UIHostingController owns the SwiftUI view's lifecycle — don't store the root view separately
- Use
sizingOptions: .intrinsicContentSize when embedding as a child for correct Auto Layout sizing
- For sheets:
hostingController.modalPresentationStyle = .pageSheet works naturally
- SwiftUI environment doesn't bridge automatically — inject dependencies through the root view's initializer
UIViewControllerRepresentable — UIKit Inside SwiftUI
Wrap existing UIKit view controllers for use in SwiftUI:
struct DocumentPickerView: UIViewControllerRepresentable {
@Binding var selectedURL: URL?
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.pdf])
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { }
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UIDocumentPickerDelegate {
let parent: DocumentPickerView
init(_ parent: DocumentPickerView) { self.parent = parent }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
parent.selectedURL = urls.first
}
}
}
When to use: Camera UI, document pickers, mail compose, any UIKit controller without a SwiftUI equivalent.
AppDelegate + SwiftUI @main
Bridge UIApplicationDelegate callbacks into a SwiftUI app:
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
RootView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
}
}
When to use: Push notifications, third-party SDKs requiring AppDelegate, background URL sessions, Handoff.
Migration Priority
When incrementally adopting SwiftUI in a UIKit app:
- Leaf screens first — Settings, About, detail views (no navigation complexity)
- New features in SwiftUI — Don't rewrite, but build new screens in SwiftUI
- Shared components — Build reusable SwiftUI components, wrap in
UIHostingController
- Navigation last — Don't mix
UINavigationController with NavigationStack in the same flow; migrate entire navigation subtrees
Don't: Replace UINavigationController with NavigationStack for half the app. Either a flow is fully SwiftUI navigation or fully UIKit navigation.
Part 6: Pressure Scenarios
Scenario 1: "Just hardcode the root for now"
The Pressure
"We only have one flow right now. Just show MainView directly, we'll add auth later."
Red Flags
- "We'll add X later" → Tech debt that compounds
- "It's just one flow" → Flows multiply
- "Keep it simple" → Simplicity now, complexity later
Time Cost Comparison
| Option | Initial | When Adding Auth | Total |
|---|
| Hardcode MainView | 0 min | 2-4 hours refactor | 2-4 hours |
| AppStateController | 30 min | 30 min add state | 1 hour |
Push-Back Script
"The AppStateController pattern takes 30 minutes now. When we add auth later — and we will — it'll take another 30 minutes to add the state. Hardcoding now saves 0 minutes because we'll spend 2-4 hours refactoring when we need auth. Let's invest 30 minutes now."
What to Do
-
Create minimal AppStateController with two states:
enum AppState {
case loading
case ready
}
-
When auth is needed, add states:
enum AppState {
case loading
case unauthenticated
case authenticated(User)
}
-
Total effort: 1 hour instead of 4 hours
Scenario 2: "We don't need modules yet"
The Pressure
"Let's keep everything in one target. Modules are over-engineering."
Decision Framework
| Codebase | Team | Recommendation |
|---|
| < 5,000 lines | 1-2 devs | Single target is fine |
| 5,000-20,000 lines | 3+ devs | Consider modules |
| > 20,000 lines | Any | Modules essential |
Push-Back Script
"I agree modules add overhead. Let's use this decision tree: We have [X] lines and [Y] developers. Based on that, we [should/shouldn't] modularize yet. If we hit [threshold], we'll revisit. Sound good?"
What to Do
- Check codebase size:
find . -name "*.swift" | xargs wc -l
- If under threshold, document decision and threshold for revisit
- If over threshold, identify natural boundaries first
Scenario 3: "Navigation is too complex to test"
The Pressure
"Testing navigation state is too hard. Let's just do manual QA."
Why This Fails
- Navigation bugs are #1 "works on my machine" cause
- Deep linking requires automated verification
- State restoration needs regression testing
- Manual QA misses edge cases
Solution: Test the State Machine
@Test func testLoginCompletesOnboarding() async {
let controller = AppStateController()
controller.transition(to: .unauthenticated)
await controller.handleLogin(user: mockUser)
#expect(controller.state == .onboarding(.welcome))
}
@Test func testDeepLinkWhileUnauthenticated() async {
let controller = AppStateController()
controller.transition(to: .unauthenticated)
let handled = controller.handleDeepLink(URL(string: "app://order/123")!)
#expect(handled == false)
#expect(controller.state == .unauthenticated)
}
Push-Back Script
"Navigation is complex, which is exactly why we need automated tests. The AppStateController pattern lets us test state transitions without launching the UI. We can verify deep linking, auth flows, and restoration in seconds. Manual QA can't catch all the combinations."
Part 7: Code Review Checklist
App State
Root View
Scene Lifecycle
Module Boundaries
Testing
Resources
WWDC: 2025-266, 2024-10150, 2023-10149, 2025-256, 2022-10054
Docs: /swiftui/scenephase, /swiftui/scene, /swiftui/scenestorage, /swiftui/windowgroup, /observation/observable()
Skills: axiom-swiftui-architecture, axiom-swiftui-nav, axiom-swift-concurrency