App Composition

SkillSecurity

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+

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the App Composition skill

What this skill tells your AI

The instructions your AI receives, as published by comeonoliver/skillshub in skills/CharlesWiltgen/Axiom/axiom-app-composition/SKILL.md and read by ahel’s review.

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 AskWhy 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:

  1. Explicit — An enum, not multiple booleans
  2. Validated — Transitions are checked and logged
  3. Centralized — One source of truth
  4. Observable — Views react to state changes

The Boolean Soup Problem

// ❌ Boolean soup — impossible to validate, prone to invalid states
class AppState {
    var isLoading = true
    var isLoggedIn = false
    var hasCompletedOnboarding = false
    var hasError = false
    var user: User?

    // What if isLoading && isLoggedIn && hasError are all true?
    // Invalid state, but nothing prevents it
}

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

    // MARK: - State Transitions

    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)
    }

    // MARK: - Validation

    private func isValidTransition(from: AppState, to: AppState) -> Bool {
        switch (from, to) {
        // From loading
        case (.loading, .unauthenticated): return true
        case (.loading, .authenticated): return true
        case (.loading, .error): return true

        // From unauthenticated
        case (.unauthenticated, .onboarding): return true
        case (.unauthenticated, .authenticated): return true
        case (.unauthenticated, .error): return true

        // From onboarding
        case (.onboarding, .onboarding): return true  // Step changes
        case (.onboarding, .authenticated): return true
        case (.onboarding, .unauthenticated): return true  // Cancelled

        // From authenticated
        case (.authenticated, .unauthenticated): return true  // Logout
        case (.authenticated, .error): return true

        // From error
        case (.error, .loading): return true  // Retry
        case (.error, .unauthenticated): return true

        default: return false
        }
    }

    // MARK: - Logging

    private func logTransition(from: AppState, to: AppState) {
        #if DEBUG
        print("AppState: \(from) → \(to)")
        #endif
    }

    private func logInvalidTransition(from: AppState, to: AppState) {
        // Log to analytics for debugging
        Analytics.log("InvalidStateTransition", properties: [
            "from": String(describing: from),
            "to": String(describing: to)
        ])
    }
}

Step 3: Initialize from Storage

extension AppStateController {
    func initialize() async {
        // Check for stored session
        if let session = await SessionStorage.loadSession() {
            // Validate session is still valid
            do {
                let user = try await AuthService.validateSession(session)
                transition(to: .authenticated(user))
            } catch {
                // Session expired or invalid
                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()

    // Loading → Unauthenticated (valid)
    controller.transition(to: .unauthenticated)
    #expect(controller.state == .unauthenticated)

    // Unauthenticated → Authenticated (valid)
    let user = User(id: "1", name: "Test")
    controller.transition(to: .authenticated(user))
    #expect(controller.state == .authenticated(user))
}

@Test func testInvalidTransitionRejected() async {
    let controller = AppStateController()

    // Loading → Onboarding (invalid — must go through unauthenticated)
    controller.transition(to: .onboarding(.welcome))
    #expect(controller.state == .loading)  // Unchanged
}

@Test func testSessionExpiredTransition() async {
    let controller = AppStateController()
    let user = User(id: "1", name: "Test")
    controller.transition(to: .authenticated(user))

    // Authenticated → Error (session expired)
    controller.transition(to: .error(.sessionExpired))
    #expect(controller.state == .error(.sessionExpired))

    // Error → Unauthenticated (force re-login)
    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.

// ✅ State-as-Bridge: UI triggers state, model does async work
struct ColorExtractorView: View {
    @State private var model = ColorExtractor()

    var body: some View {
        Button("Extract Colors") {
            // ✅ Synchronous state change triggers animation
            withAnimation { model.isExtracting = true }

            // Async work happens in Task
            Task {
                await model.extractColors()

                // ✅ Synchronous state change ends animation
                withAnimation { model.isExtracting = false }
            }
        }
        .scaleEffect(model.isExtracting ? 1.5 : 1.0)
    }
}

@Observable
class ColorExtractor {
    var isExtracting = false
    var colors: [Color] = []

    func extractColors() async {
        // Heavy computation happens here, testable without SwiftUI
        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)
            }
        }
    }
}

Testability Benefit

The thin-shell pattern enables up to 60x faster tests. When app logic lives in a Swift Package instead of the app target, tests run with swift test (~0.4s) vs xcodebuild test (~25s) — no simulator, no app launch.

ComponentLocationTested With
Business logic, models, servicesSwift Package (MyAppCore)swift test (0.4s)
Root view compositionApp target (thin shell)xcodebuild test (25s)

See axiom-swift-testing Strategy 1 for the complete package extraction walkthrough.

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()

        // Do actual initialization
        await performInitialization()

        // Ensure minimum display time for loading screen
        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

PhaseDescription
.activeScene is in the foreground and interactive
.inactiveScene is in the foreground but should pause work
.backgroundScene 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:
            // App became active — validate session, refresh data
            Task {
                await appState.validateSession()
                await appState.refreshIfNeeded()
            }

        case .inactive:
            // App about to go inactive — save state
            appState.prepareForBackground()

        case .background:
            // App in background — release resources
            appState.releaseResources()

        @unknown default:
            break
        }
    }
}

Session Validation on Active

extension AppStateController {
    func validateSession() async {
        guard case .authenticated(let user) = state else { return }

        do {
            // Check if token is still valid
            let isValid = try await AuthService.validateToken(user.token)
            if !isValid {
                transition(to: .error(.sessionExpired))
            }
        } catch {
            // Network error — keep authenticated but show warning
            // Don't immediately log out on transient network issues
        }
    }

    func prepareForBackground() {
        // Save any pending data
        // Cancel non-essential network requests
        // Prepare for potential termination
    }

    func releaseResources() {
        // Release cached images
        // Stop location updates if not essential
        // Reduce memory footprint
    }
}

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 {
                // Restore to last viewed item
                navigateToItem(itemID)
            }
        }
    }
}

Navigation State Restoration (WWDC 2022/10054)

For complex navigation, use a Codable NavigationModel:

// Encapsulate navigation state with Codable conformance
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)
        // Store only IDs, not full models
        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)
        // compactMap discards deleted items gracefully
        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
        }
    }
}

// Use with SceneStorage
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 {
                // Validate item still exists
                item = await ItemService.fetch(itemID)
                if item == nil {
                    // Item was deleted — clear restoration
                    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 {
        // Primary window
        WindowGroup {
            MainView()
                .environment(appState)
        }

        // Data-presenting window (iPad)
        // Prefer lightweight data (IDs, not full models)
        WindowGroup("Detail", id: "detail", for: Item.ID.self) { $itemID in
            if let itemID {
                DetailView(itemID: itemID)
                    .environment(appState)
            }
        }

        #if os(visionOS)
        // Immersive space
        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) {
            // Open in new window on iPad
            // Use ID to match window group, value to pass data
            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()  // Closes this window
            }
        }
    }
}

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

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
63
Forks
22
Last commit
Jun 2026
Advanced
Catalog kind
skill
Gateway key
axiom-app-composition
Source
github.com/comeonoliver/skillshub