In the design-patterns breakdown on this blog, I ended the coordinator section with a verdict that has aged well: SwiftUI didn’t kill coordinators — it shrank their job to flow orchestration. NavigationStack(path:) made per-screen coordination redundant, but it did not make coordination obsolete. Auth gating, deep links, multi-window behavior, and cross-cutting flows still need an owner that lives above any single view, and the pattern earns its keep exactly there — the case I made in Design Patterns in Swift: A Practical Guide.

This post builds those coordinators properly. Not the UIKit-era childCoordinators array, not a coordinator per screen — a route value type as navigation state, and one @Observable coordinator per flow that composes with NavigationStack, never against it. Everything here targets iOS 17+, so we get the Observation framework, and I’ll use a running example: a shop app with a catalog, a cart, a checkout flow, onboarding, and deep links.

1. The Coordinator Model: A Route Value Type and an Observable Owner

The modern coordinator has two halves. The first is the navigation state itself: a Route enum with associated values, which is what actually gets pushed onto the stack.

import Foundation

struct Product: Identifiable, Hashable {
    let id: UUID
    let name: String
    let price: Decimal
}

struct Order: Identifiable, Hashable {
    let id: UUID
}

enum Route: Hashable {
    case productDetail(Product.ID)
    case cart
    case checkout
    case orderConfirmation(Order.ID)
    case profile
    case settings
}

enum AppTab: Int, Hashable {
    case catalog
    case cart
    case profile
}

The second half is the owner: an @MainActor @Observable class that holds the path and the entry-point decisions. This is the Observation-framework pattern I covered in Swift Observation and @Observable: Modern State Management applied to navigation — and it matters, because the coordinator is shared mutable state that many views read, which is precisely the case @Observable was built for.

import Observation

@MainActor
@Observable
final class AppCoordinator {
    enum Root: Hashable {
        case onboarding
        case main
    }

    var root: Root = .onboarding
    var selectedTab: AppTab = .catalog
    var path: [Route] = []

    private(set) var pendingTarget: DeepLinkTarget?
    private let resolver: DeepLinkResolver
    let onboarding: OnboardingCoordinator

    init(resolver: DeepLinkResolver = DeepLinkResolver()) {
        self.resolver = resolver
        self.onboarding = OnboardingCoordinator()
        self.onboarding.onFinished = { [weak self] in
            self?.completeOnboarding()
        }
    }

    // MARK: - Navigation API

    func push(_ route: Route) {
        path.append(route)
    }

    func dismiss() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path.removeAll()
    }

    // MARK: - Entry points

    func completeOnboarding() {
        root = .main
        guard let target = pendingTarget else { return }
        pendingTarget = nil
        apply(target)
    }

    func signOut() {
        root = .onboarding
        path.removeAll()
        pendingTarget = nil
    }

    // MARK: - Deep links

    func handle(_ url: URL) {
        guard let target = resolver.resolve(url) else { return }
        if root == .main {
            apply(target)
        } else {
            pendingTarget = target
        }
    }

    private func apply(_ target: DeepLinkTarget) {
        selectedTab = target.tab
        path.removeAll()
        path.append(target.route)
    }
}

Two deliberate choices. The path is a typed array of route values — copyable, comparable, trivially assertable in tests — while the property itself stays settable so SwiftUI can bind to it. push, dismiss, and popToRoot are the only mutation surface the rest of the app should use. And root is the single knob for the app’s entry point — more on that shortly.

The view side composes with NavigationStack, never around it. The coordinator is injected once at the top of the tree and read via @Environment:

struct MainTabView: View {
    @Environment(AppCoordinator.self) private var coordinator

    var body: some View {
        @Bindable var coordinator = coordinator

        NavigationStack(path: $coordinator.path) {
            TabView(selection: $coordinator.selectedTab) {
                CatalogView()
                    .tabItem { Label("Catalog", systemImage: "square.grid.2x2") }
                    .tag(AppTab.catalog)
                CartView()
                    .tabItem { Label("Cart", systemImage: "cart") }
                    .tag(AppTab.cart)
                ProfileView()
                    .tabItem { Label("Profile", systemImage: "person") }
                    .tag(AppTab.profile)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .productDetail(let id): ProductDetailView(id: id)
                case .cart:                 CartView()
                case .checkout:             CheckoutView()
                case .orderConfirmation(let id): OrderConfirmationView(id: id)
                case .profile:              ProfileView()
                case .settings:             SettingsView()
                }
            }
        }
    }
}

One layout note: because the TabView is the stack’s root, a push covers the tab bar. To keep pushes inside a tab (catalog → detail with the tab bar visible), attach a navigationDestination to each tab’s content instead — the coordinator’s API doesn’t change. This is also where the coordinator lives on the architecture map: the Presentation layer, alongside view models, which is exactly where I placed it in MVVM with Clean Architecture in iOS: A Practical Guide. The Domain and Data layers never hear about routes.

Why a typed [Route] instead of NavigationPath? Honest answer: both work, and NavigationPath is the right call when you need to mix heterogeneous route types in one stack or persist navigation state via Codable. But for most apps the typed array wins, because every assertion in your tests becomes #expect(coordinator.path == [.checkout]) — no stringly-typed comparison, no opaque NavigationPath internals to introspect. You can always swap the property type later without touching the views.

Deep links are where a plain view-local path breaks down. A NavigationStack inside CatalogView has no idea that warmodroid://cart should switch tabs and then push checkout. The coordinator does — it owns both the tab selection and the stack.

The mapping from URL to route stays a small, pure value: a resolver struct with no state, which makes it trivially unit-testable.

struct DeepLinkTarget: Equatable {
    let tab: AppTab
    let route: Route
}

struct DeepLinkResolver {
    func resolve(_ url: URL) -> DeepLinkTarget? {
        guard let host = url.host() else { return nil }

        switch host {
        case "product":
            guard let id = UUID(uuidString: url.lastPathComponent) else { return nil }
            return DeepLinkTarget(tab: .catalog, route: .productDetail(id))
        case "cart":
            return DeepLinkTarget(tab: .cart, route: .cart)
        case "checkout":
            return DeepLinkTarget(tab: .cart, route: .checkout)
        default:
            return nil
        }
    }
}

Wiring it up is a single modifier at the scene root:

@main
struct ShopApp: App {
    @State private var coordinator = AppCoordinator()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(coordinator)
                .onOpenURL { url in
                    coordinator.handle(url)
                }
        }
    }
}

The subtle part — and the one I’ve watched teams get wrong — is the cold launch vs. running app distinction. onOpenURL fires in both cases, so you do not need to branch on launch state. But on a cold launch through a URL, the app’s session isn’t ready: the user hasn’t signed in, onboarding hasn’t run, and root is still .onboarding. If the coordinator tried to apply the target immediately, it would push a checkout route onto a stack that doesn’t exist yet.

That’s why handle(_:) queues: if the main flow isn’t active, the target is parked in pendingTarget, and completeOnboarding() applies it the moment auth gating finishes. The user opens a checkout link, gets signed in through onboarding, and lands directly on checkout — with zero manual bookkeeping. (Registering the scheme in Info.plist or setting up Associated Domains is a separate, well-documented step; the SwiftUI side is exactly what’s above.)

3. Auth Gating: The Coordinator Decides the Root

Root switching is where the UIKit coordinator’s old window.rootViewController = ... dance becomes a single @Observable property. root is the entry-point resolution: the view tree reads it, and SwiftUI handles the swap declaratively.

struct RootView: View {
    @Environment(AppCoordinator.self) private var coordinator
    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        switch coordinator.root {
        case .onboarding:
            OnboardingCoordinatorView()
        case .main:
            MainTabView()
        }
        .onChange(of: scenePhase) { _, phase in
            if phase == .active {
                Task { await coordinator.refreshSessionIfNeeded() }
            }
        }
    }
}

extension AppCoordinator {
    func refreshSessionIfNeeded() async {
        // Re-validate the session token, e.g. through a SessionService.
        // If it expired: signOut()
    }
}

There is no imperative view-hierarchy surgery. completeOnboarding() flips root from .onboarding to .main, the switch re-evaluates, and the whole tab flow replaces the onboarding flow in one transaction. Add an .animation(.default, value: coordinator.root) if you want the transition to feel deliberate. Sign-out works the same way in reverse: signOut() resets the path so the next user never inherits the previous one’s navigation state — a bug class that view-local paths handle badly, because nothing owns the reset.

The scene-phase hook is a pragmatic extra: re-validating the session on foreground is exactly the cross-cutting concern that shouldn’t live in a view model, and it gives the coordinator a natural place to expire stale sessions and kick the user back to onboarding.

4. Child Coordinators: Nested Flows With Their Own Paths

Not every flow belongs to the app-level stack. Onboarding is a self-contained journey with its own steps; a checkout flow is the same shape. Each is a child coordinator: its own route type, its own path, its own stack — and a single, explicit way to hand results back to the parent.

enum OnboardingRoute: Hashable {
    case welcome
    case notifications
    case done
}

@MainActor
@Observable
final class OnboardingCoordinator {
    var path: [OnboardingRoute] = []
    var onFinished: (() -> Void)?

    func start() {
        path = [.welcome]
    }

    func next() {
        switch path.last {
        case .welcome:
            path.append(.notifications)
        case .notifications:
            path.append(.done)
        case .done, nil:
            path.removeAll()
            onFinished?()
        }
    }

    func skip() {
        path.removeAll()
        onFinished?()
    }
}

The parent owns the child — AppCoordinator.onboarding — and stays the single source of truth. The child owns only its own path. Results flow back through the onFinished closure, which the parent wires up in its initializer to call completeOnboarding(). The view side is the same composition as before, just nested — note the stack root is a transient splash, and start() pushes .welcome as the first step so no screen ever renders twice:

struct OnboardingCoordinatorView: View {
    @Environment(AppCoordinator.self) private var app

    var body: some View {
        @Bindable var onboarding = app.onboarding

        NavigationStack(path: $onboarding.path) {
            OnboardingSplashView()   // transient root; every step is a route
                .navigationDestination(for: OnboardingRoute.self) { route in
                    switch route {
                    case .welcome:      WelcomeView()
                    case .notifications: NotificationsPermissionView()
                    case .done:         OnboardingDoneView()
                    }
                }
        }
        .task { onboarding.start() }
    }
}

Now the honest part: the UIKit childCoordinators array is not something to port. That array existed because UIKit coordinators were objects with lifetimes that needed manual management — keep a strong reference or it deallocates. In SwiftUI, the view tree is the ownership graph. AppCoordinator holds onboarding as a property; that is the strong reference, and the view tree owns AppCoordinator. The childCoordinators array was solving a memory-management problem SwiftUI already solved. Adding it back is cargo cult. What survives is the shape — parent owns child, child reports results — expressed as ordinary object composition and a closure.

For an incremental migration where UIKit still owns the outer coordinator, embedding SwiftUI with UIHostingController keeps that navigation boundary explicit while SwiftUI owns the feature’s internal view hierarchy.

If several flows need to react to the same event (say, “order placed” should pop checkout and refresh the cart badge), a closure chain gets awkward. That’s the moment for a shared route-event channel — a typed event bus or a @Observable session object both flows observe — but start with closures. They keep the data flow visible, and you can always widen them later.

5. Multi-Window and Scene Lifecycle: Own Above the View Tree

The clearest case for an App-owned coordinator: an iPad user opens a product detail, and then the system discards that scene — the window closed, or memory pressure tore the view tree down. With @State var path inside a view, that navigation state is gone. The user’s context evaporates.

Because the coordinator lives at the App level — @State on ShopApp, injected via .environment(_:) — it survives scene teardown and recreation. The view tree rebuilds from scratch, and MainTabView re-binds to coordinator.path, which still holds the detail route. Navigation state belongs to the process, not to a transient view hierarchy.

The caveat, and I want to be upfront about it: on iPad, every WindowGroup scene shares that one App-level coordinator, so all windows show the same stack. If you genuinely need independent per-window navigation, scope a coordinator per scene instead — and accept that per-scene state dies with the scene. And @State at the App level is not persistence: if you want the stack to survive termination, add Codable to Route and restore path from its serialized form on launch. Don’t reach for @SceneStorage here — it only accepts plist-compatible values, and an enum with associated values doesn’t qualify. Most apps don’t need that last step; most apps do benefit from state surviving a scene swap — and the App-owned coordinator is the cheap way to get it.

6. Testing: Navigation State as a Value

This is where the whole design pays for itself. Because the coordinator’s navigation state is a typed value, testing navigation is ordinary unit testing — no XCUIApplication, no waiting for animations, no UI process. One honest caveat: @testable import ShopApp still runs with the app target as the test host; a truly host-free setup means moving the coordinator into a framework target. Either way, the routing logic never needs the UI running. I covered the framework itself in Swift Testing: A Practical Guide for Senior Engineers; here’s what coordinator tests look like with it.

import Testing
@testable import ShopApp

@Suite("AppCoordinator")
@MainActor
struct AppCoordinatorTests {
    private let productID = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!

    @Test func pushAppendsRouteToPath() {
        let coordinator = AppCoordinator()
        coordinator.root = .main
        coordinator.push(.checkout)
        #expect(coordinator.path == [.checkout])
    }

    @Test func dismissPopsLastRoute() {
        let coordinator = AppCoordinator()
        coordinator.root = .main
        coordinator.push(.productDetail(id: productID))
        coordinator.push(.cart)
        coordinator.dismiss()
        #expect(coordinator.path == [.productDetail(id: productID)])
    }

    @Test func deepLinkSelectsTabAndPushesRoute() {
        let coordinator = AppCoordinator()
        coordinator.root = .main
        coordinator.handle(URL(string: "warmodroid://cart")!)
        #expect(coordinator.selectedTab == .cart)
        #expect(coordinator.path == [.cart])
    }

    @Test func deepLinkWhileOnboardingIsDeferredUntilSignIn() {
        let coordinator = AppCoordinator()
        coordinator.handle(URL(string: "warmodroid://checkout")!)
        #expect(coordinator.pendingTarget != nil)
        #expect(coordinator.path.isEmpty)

        coordinator.completeOnboarding()
        #expect(coordinator.root == .main)
        #expect(coordinator.selectedTab == .cart)
        #expect(coordinator.path == [.checkout])
    }
}

@Suite("DeepLinkResolver")
struct DeepLinkResolverTests {
    private let productID = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!

    @Test func resolvesProductURL() throws {
        let resolver = DeepLinkResolver()
        let target = try #require(resolver.resolve(
            URL(string: "warmodroid://product/\(productID.uuidString)")!
        ))
        #expect(target.tab == .catalog)
        #expect(target.route == .productDetail(id: productID))
    }

    @Test(arguments: ["warmodroid://", "warmodroid://product/not-a-uuid"])
    func rejectsUnresolvableURLs(_ url: String) {
        #expect(DeepLinkResolver().resolve(URL(string: url)!) == nil)
    }
}

Note what isn’t being tested: rendering. Whether ProductDetailView looks right is a preview or a UI test problem. What the coordinator owns — which tab, which stack, what’s queued, when it’s applied — is pure state transformation, and it’s all here in milliseconds. That division is the entire point of value-type navigation state.

7. Tradeoffs: When the Coordinator Is — and Isn’t — Worth It

I have built the coordinator-per-screen version of this. It is a museum piece. One coordinator object per pushed screen recreates every bit of UIKit ceremony NavigationStack was designed to remove, and it earns nothing: no shared state, no cross-cutting logic, just indirection. If you catch a teammate naming a type ProductDetailCoordinator, have a conversation.

The honest sizing rule: use a coordinator when navigation state needs an owner beyond the current view. Auth gating, deep links, multi-window, flows that must reset or restore, flows whose state other parts of the app need to observe. For a linear stack of five screens with none of that — a settings drill-down, a wizard whose only concern is the back button — a plain @State var path in the root view plus .navigationDestination is simpler, shorter, and easier to read. Adding a coordinator there is over-abstraction — and over-abstraction is how a codebase gets a navigation layer nobody dares touch.

Centralizing navigation also has real costs. The coordinator is one more type to keep in your head, and a property that was previously local now lives behind a longer mental hop. Because it’s shared at the App level, every view that mutates it goes through one object — a feature for consistency, a hazard for coupling, since the coordinator can quietly become the app’s grab-bag. And under Swift 6 strict concurrency, the @MainActor isolation that keeps @Observable state safe means the coordinator must never be touched from background contexts — a discipline the compiler now enforces, not a suggestion.

If you take one thing from this post, take the verdict: coordinators in SwiftUI are not a pattern to resurrect and not a pattern to abandon — they are a pattern to right-size. Route value types own the state, NavigationStack owns the mechanics, and a small @Observable coordinator owns the flows that cross screens, tabs, sessions, and windows.

Key Takeaways

  • Navigation state is a value. A Route enum with associated values plus a typed path array is testable, resettable, and serializable in a way view-controller hierarchies never were.
  • The coordinator is flow-scoped, not screen-scoped. One @MainActor @Observable object per flow — app, onboarding, checkout — owning path, root, and entry-point decisions.
  • Compose with NavigationStack, don’t fight it. The coordinator exposes path as a binding; NavigationStack(path:) and .navigationDestination(for:) do the rendering.
  • Deep links are a resolver plus a queue. Map URL to (tab, route) in a pure struct; apply it when the main flow is active, park it in pendingTarget until auth completes otherwise.
  • Auth gating is a root property. The view tree switches declaratively; the coordinator owns the reset so sessions never leak navigation state.
  • Children keep their own paths and report results through closures. Parent ownership replaces the UIKit childCoordinators array — the array was memory management, and SwiftUI already solved that.
  • Test routing as state transformation. With Swift Testing, assert pushes, pops, tab selection, and deep-link queuing directly on the coordinator — no UI tests for routing logic.
  • Right-size the pattern. Linear flows without cross-cutting concerns don’t need a coordinator; simple @State paths win there. Coordinator-per-screen is cargo cult, always.