Introduction

In my previous post, I surveyed three approaches to SwiftUI navigation at scale: shared routers, navigation frameworks, and per-module coordinators. I explained the trade-offs of each.

This post is the implementation. A demo project with five feature modules, a tab bar, a modal settings flow, an onboarding gate, deep linking, and unit tests. The architecture:

  • Vertical feature modules that can’t see each other
  • A generic Router per module that holds navigation state
  • Each module composes its own views internally
  • A single AppCoordinator for cross-module orchestration
  • No coordinator per feature module

The Module Structure

App (composition root)
├── Home        (tab — list → detail push)
├── Profile     (tab — static screen, edit sheet)
├── Account     (tab — logout action)
├── Onboarding  (separate flow — experiment-based branching)
├── UserSettings (modal sheet)
└── Navigation  (shared — Router generic)

Each feature package depends on Navigation but never on each other. The Navigation package contains only the generic Router. Pure infrastructure with no app-specific types. The App target is the only thing that imports everything.


What Module Boundaries Do to Navigation

In a horizontally modularised app with shared destinations, views can navigate directly. Every module sees every destination type.

In a vertically modularised app, this won’t compile. The Home module defines HomePushDestination. It has .detail(item:). That’s it. There is no .profileEdit, no .securitySettings. Those types live in other packages that Home can’t see. The compiler enforces the boundary.

So views express intent through closures:

1
2
3
4
5
6
7
ForEach(items) { item in
    Button {
        onSelectItem(item)
    } label: {
        ItemCardView(item: item)
    }
}

The view doesn’t know where it’s going. The composition root decides:

1
2
3
onSelectItem: { item in
    router.push(.detail(item: item))
}

More wiring. But the compiler enforces module isolation. Cross-module navigation can only happen through the composition root.


The Router

The Router is a generic class that owns all navigation state for a single flow. It lives in the shared Navigation package.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

@MainActor
@Observable
public final class Router<Push: Hashable, Sheet: Hashable & Identifiable> {
    public var path = NavigationPath()
    public var sheet: Sheet?

    public init() {}

    public func push(_ destination: Push) {
        path.append(destination)
    }

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

    public func popToRoot() {
        path = NavigationPath()
    }

    public func present(sheet destination: Sheet) {
        sheet = destination
    }

    public func dismissSheet() {
        sheet = nil
    }
}

Each feature module types the Router to its own destinations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
enum HomePushDestination: Hashable {
    case detail(item: ListItem)
}

enum HomeSheetDestination: Hashable, Identifiable {
    case itemActions(item: ListItem)

    var id: String {
        switch self {
        case .itemActions(let item): "itemActions-\(item.id)"
        }
    }
}

The Router doesn’t know what a ListItem is. It doesn’t know what views exist. It’s a state container. Nothing more.


The Flow View

Each module owns a flow view that binds the Router’s state to SwiftUI’s navigation primitives and composes its own internal views. The module handles its own wiring. The app target only needs to provide the Router and any cross-module closures.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public struct HomeFlowView: View {
    @Bindable private var router: Router<HomePushDestination, HomeSheetDestination>
    private let onOpenSettings: () -> Void

    public init(
        router: Router<HomePushDestination, HomeSheetDestination>,
        onOpenSettings: @escaping () -> Void
    ) {
        self.router = router
        self.onOpenSettings = onOpenSettings
    }

    public var body: some View {
        NavigationStack(path: $router.path) {
            ListView(
                onSelectItem: { item in router.push(.detail(item: item)) },
                onOpenSettings: onOpenSettings
            )
            .navigationDestination(for: HomePushDestination.self) { destination in
                view(for: destination)
            }
        }
        .sheet(item: $router.sheet) { destination in
            sheetView(for: destination)
        }
    }

    @ViewBuilder
    private func view(for destination: HomePushDestination) -> some View {
        switch destination {
        case .detail(let item):
            ListDetailView(
                viewModel: ListDetailViewModel(
                    item: item,
                    onBack: { router.pop() },
                    onShowActions: { router.present(sheet: .itemActions(item: item)) }
                )
            )
        }
    }

    @ViewBuilder
    private func sheetView(for destination: HomeSheetDestination) -> some View {
        switch destination {
        case .itemActions(let item):
            ItemActionsView(item: item, onDismiss: { router.dismissSheet() })
        }
    }
}

The view factory methods are private. ListDetailView, ListDetailViewModel, and ItemActionsView are all internal to the Home module. The composition root never sees them.


The Composition Root

AppComposition is the only file that imports every feature module. It creates each module’s flow view, passing the Router and any cross-module closures. Right now each method is a one-liner, but this is where scaling happens. When you add networking clients, analytics services, feature flags, or new modules, they all land here. AppComposition distributes dependencies to modules that can’t see each other.

AppRootView never imports a feature module directly. It calls AppComposition and receives opaque views. As the app grows, new modules are added in one place. The root view never changes.


Where the Router Alone Breaks Down

Within a module, the Router handles everything cleanly. But at the app level, some actions affect multiple modules simultaneously: logout needs to reset three routers, dismiss a sheet, and swap the root screen. A deep link needs to pick the right tab, clear stale navigation, and push a screen. Onboarding completion swaps the entire UI.

Without a coordinator, this logic ends up inline in the view body. Five lines for logout, fifteen for deep links, growing with every new requirement. The view becomes a god view.


The AppCoordinator

The AppCoordinator is the only app-level coordinator. Individual modules use Routers directly unless their flow logic demands otherwise (more on that in the onboarding section below).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@MainActor
final class AppCoordinator: ObservableObject {
    @Published var screen: RootScreen = .onboarding
    @Published var selectedTab: AppTab = .home
    @Published var isPresentingSettings = false

    private(set) var homeRouter = Router<HomePushDestination, HomeSheetDestination>()
    private(set) var profileRouter = Router<ProfilePushDestination, ProfileSheetDestination>()
    private(set) var accountRouter = Router<AccountPushDestination, AccountSheetDestination>()

    private(set) var onboardingExperiment: OnboardingExperiment = .signUpFirst

    func completeOnboarding() {
        screen = .authenticated
    }

    func logout() {
        homeRouter.popToRoot()
        profileRouter.popToRoot()
        accountRouter.popToRoot()
        isPresentingSettings = false
        screen = .onboarding
    }

    func openSettings() {
        isPresentingSettings = true
    }

    func dismissSettings() {
        isPresentingSettings = false
    }

    func handleDeepLink(_ url: URL) {
        guard let destination = DeepLinkParser.parse(url) else { return }

        switch destination {
        case .tab(let tab):
            screen = .authenticated
            selectedTab = tab

        case .homeDetail(let itemID):
            screen = .authenticated
            selectedTab = .home
            homeRouter.popToRoot()
            homeRouter.push(.detail(item: ListItem(id: itemID, title: "Deep Link Item", subtitle: "Opened via deep link")))

        case .settings:
            screen = .authenticated
            isPresentingSettings = true
        }
    }
}

Every method represents a real decision. logout() resets three routers and swaps the root screen. handleDeepLink() dispatches across tabs and presentation styles. None are pass-throughs.

Modules never import the AppCoordinator. Communication flows upward through closures. The AppCoordinator is the hub. Modules are spokes. Closures are the wires.

The root view becomes purely declarative. It imports only SwiftUI and Navigation, calls AppComposition for every module, and never knows what feature modules exist:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import SwiftUI
import Navigation

struct AppRootView: View {
    @StateObject var coordinator: AppCoordinator

    var body: some View {
        switch coordinator.screen {
        case .onboarding:
            AppComposition.makeOnboardingFlowView(
                experiment: coordinator.onboardingExperiment,
                onComplete: coordinator.completeOnboarding
            )
        case .authenticated:
            TabView(selection: $coordinator.selectedTab) {
                AppComposition.makeHomeFlowView(
                    router: coordinator.homeRouter,
                    onOpenSettings: coordinator.openSettings
                )
                .tag(AppTab.home)
                .tabItem { Label("Home", systemImage: "house") }

                AppComposition.makeProfileFlowView(router: coordinator.profileRouter)
                    .tag(AppTab.profile)
                    .tabItem { Label("Profile", systemImage: "person") }

                AppComposition.makeAccountFlowView(
                    router: coordinator.accountRouter,
                    onLogout: coordinator.logout,
                    onOpenSettings: coordinator.openSettings
                )
                .tag(AppTab.account)
                .tabItem { Label("Account", systemImage: "gearshape") }
            }
            .sheet(isPresented: $coordinator.isPresentingSettings) {
                AppComposition.makeSettingsFlowView(
                    onDismiss: coordinator.dismissSettings
                )
            }
            .onOpenURL(perform: coordinator.handleDeepLink)
        }
    }
}

When a Module Earns Its Own Coordinator

The demo project’s onboarding is simple: welcome, sign in, done. But real onboarding isn’t linear. It branches based on experiments, user state, and async results:

Welcome
  → Experiment A: Value prop video → Sign up → Notifications → Home
  → Experiment B: Sign up → Profile setup → Notifications → Home
  → Experiment C: Sign up → Home (skip everything)

A Router can’t handle this. It pushes and pops. It doesn’t decide what to push based on an experiment flag. And you can’t hardcode the destination in the view because you don’t know it at compile time:

1
2
3
4
// What goes here? It depends on the experiment.
Button("Continue") {
    onContinue() // The view doesn't know. It just fires intent.
}

This is where a module-level coordinator earns its place. The Onboarding module gets its own OnboardingCoordinator that owns the branching logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@MainActor
@Observable
final class OnboardingCoordinator {
    let router = Router<OnboardingStep, OnboardingSheet>()
    private let experiment: OnboardingExperiment
    private let onComplete: () -> Void

    init(experiment: OnboardingExperiment, onComplete: @escaping () -> Void) {
        self.experiment = experiment
        self.onComplete = onComplete
    }

    func didCompleteWelcome() {
        switch experiment {
        case .videoFirst:
            router.push(.valuePropVideo)
        case .signUpFirst, .minimal:
            router.push(.signUp)
        }
    }

    func didCompleteSignUp() {
        switch experiment {
        case .videoFirst:
            router.push(.notifications)
        case .signUpFirst:
            router.push(.profileSetup)
        case .minimal:
            onComplete()
        }
    }

    func didCompleteProfileSetup() {
        router.push(.notifications)
    }

    func didCompleteNotifications() {
        onComplete()
    }
}

The views stay dumb. They fire closures like onContinue without knowing what screen comes next. The coordinator makes that decision based on the experiment. The Router handles the push. Three layers, three responsibilities.

The AppCoordinator doesn’t know any of this exists. It still just passes a closure through AppComposition:

1
2
3
4
AppComposition.makeOnboardingFlowView(
    experiment: coordinator.onboardingExperiment,
    onComplete: coordinator.completeOnboarding
)

The experiment assignment comes from outside the module. The branching logic lives inside the module. The completion signal flows upward. Nothing leaks across boundaries.

This is the pattern applied consistently: Router for mechanics, coordinator for decisions, closures for communication. Home doesn’t need a coordinator because tapping a list item always pushes a detail screen. Onboarding does because the next screen depends on state that varies at runtime. You add the complexity where the complexity lives, not everywhere as a default.


Deep Linking

The DeepLinkParser and AppDestination live in the app target. They’re app-level concerns, not shared infrastructure. The Navigation package stays pure: just the generic Router, reusable across projects.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
enum AppTab: String, Hashable {
    case home
    case profile
    case account
}

enum AppDestination: Equatable {
    case tab(AppTab)
    case homeDetail(itemID: UUID)
    case settings
}

struct DeepLinkParser {
    private static let scheme = "coordinatordemo"

    static func parse(_ url: URL) -> AppDestination? {
        guard url.scheme == scheme else { return nil }

        let components = url.pathComponents.filter { $0 != "/" }

        switch components.first {
        case "home":
            if components.count == 1 { return .tab(.home) }
            if components.count == 3,
               components[1] == "item",
               let id = UUID(uuidString: components[2]) {
                return .homeDetail(itemID: id)
            }
            return nil
        case "profile": return .tab(.profile)
        case "account": return .tab(.account)
        case "settings": return .settings
        default: return nil
        }
    }
}

Testable without importing any feature module.


Testing

Everything is testable without a SwiftUI view hierarchy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func test_logout_resetsAllRoutersAndShowsOnboarding() {
    let coordinator = AppCoordinator()
    coordinator.screen = .authenticated
    coordinator.homeRouter.push(.detail(item: ListItem.fixture()))
    coordinator.isPresentingSettings = true

    coordinator.logout()

    XCTAssertEqual(coordinator.screen, .onboarding)
    XCTAssertTrue(coordinator.homeRouter.path.isEmpty)
    XCTAssertFalse(coordinator.isPresentingSettings)
}

func test_handleDeepLink_switchesTabAndPushesDetail() {
    let coordinator = AppCoordinator()
    let id = UUID()
    let url = URL(string: "coordinatordemo:///home/item/\(id.uuidString)")!

    coordinator.handleDeepLink(url)

    XCTAssertEqual(coordinator.screen, .authenticated)
    XCTAssertEqual(coordinator.selectedTab, .home)
    XCTAssertEqual(coordinator.homeRouter.path.count, 1)
}

No ViewInspector. No mock coordinators. Plain unit tests against observable state.


When to Use What

Router only: when intent maps 1:1 to a destination. This covers most feature modules.

Coordinator: when the response to an intent touches multiple modules or depends on application state. This typically lives at the app level, not per feature.

The wrong instinct is to add a coordinator to every module “for consistency.” If its methods are all one-liners that forward to the Router, delete it.


Final Thoughts

The vertical split gives you isolated compilation. Per-module destinations give you compiler-enforced ownership. Closures give you explicit, testable dependencies. The AppCoordinator gives you a single coordination point where module boundaries meet.

The trade-off is more wiring than a shared-destination approach. But in a codebase with multiple teams and dozens of modules, the independence is worth it.

Start simple. Add structure when the codebase tells you it needs it, not before.

The full demo project is on GitHub.