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.