When building highly modular iOS apps, one challenge stands out: how do you handle navigation without coupling your feature modules to specific implementation details? After experimenting with various approaches, I’ve settled on a pattern that provides both flexibility and testability while keeping concerns properly separated.
The Problem with Traditional Navigation
In typical SwiftUI apps, navigation often happens directly within feature views. You’ll see NavigationStack wrapping views, and navigation state scattered throughout your feature code. While this works for simple apps, it creates several problems:
- Tight coupling: Features become dependent on SwiftUI’s navigation APIs
- Limited flexibility: Changing navigation strategies requires modifying feature code
- Testing difficulties: Navigation logic becomes harder to test in isolation
- Context limitations: Individual features lack awareness of the broader navigation hierarchy
- Cross-feature navigation nightmares: In modularized apps, showing a screen from Feature B within Feature A’s flow becomes an architectural puzzle, leading to awkward workarounds and feature interdependencies
The Coordinator Protocol Pattern
My solution delegates all navigation decisions to the Application layer through protocol-based coordinators. Here’s how it works:
In the Feature Module
Features define navigation requirements as protocols, without implementation:
@MainActor
public protocol GroupsCoordinator {
func detail(for group: SpendidDomain.Group)
func create(didCreate: @escaping (Group) -> Void)
}Feature views receive coordinators through dependency injection:
public struct GroupsListView: View {
private let coordinator: GroupsCoordinator
public var body: some View {
List(groups) { group in
GroupRowView(group: group)
.onTapGesture {
coordinator.detail(for: group)
}
}
.navigationTitle("Split The Bill")
}
}Notice that GroupsListView never wraps itself in a NavigationStack. It simply delegates navigation as a side effect to the coordinator. The view doesn’t know or care how navigation is implemented—whether through SwiftUI, UIKit, or any other mechanism.
In the Application Module
The Application layer implements coordinators with full knowledge of the navigation hierarchy:
@MainActor
@Observable
final class GroupsCoordinator: GroupsCoordinator, GroupDetailCoordinator {
var routes: [Route] = []
var modal: Modal?
enum Route: Hashable {
case group(RouteIdentifier<Group>)
}
enum Modal: Identifiable {
case createGroup((Group) -> Void)
case createExpense(Group, (Expense) -> Void)
var id: String {
switch self {
case .createGroup: return "createGroup"
case .createExpense: return "createExpense"
}
}
}
func detail(for group: Group) {
routes.append(.group(.init(value: group, id: \.id)))
}
func create(didCreate: @escaping (Group) -> Void) {
modal = .createGroup { [weak self] group in
didCreate(group)
self?.modal = nil
}
}
}The navigation view lives in the Application layer, managing the entire navigation stack:
struct GroupsNavigationView: View {
@Bindable
private var coordinator: GroupsCoordinator
var body: some View {
NavigationStack(path: $coordinator.routes) {
GroupsListView(coordinator: coordinator)
.sheet(item: $coordinator.modal) { modal in
switch modal {
case .createGroup(let onComplete):
NavigationView {
CreateGroupView(onCreate: onComplete)
}
case .createExpense(let group, let onComplete):
NavigationView {
CreateExpenseView(group: group, onCreate: onComplete)
}
}
}
.navigationDestination(for: GroupsCoordinator.Route.self) { route in
switch route {
case .group(let id):
GroupDetailView(group: id.value, coordinator: coordinator)
}
}
}
}
}RouteIdentifier: Solving the ViewModel Lifecycle Problem
Here’s a critical difference between UIKit and SwiftUI that affects architecture: in UIKit, ViewControllers stayed in memory as long as they were in the navigation stack. This meant your ViewModels could live as properties of ViewControllers, with guaranteed lifecycle.
SwiftUI Views, however, are merely value types—descriptions of the UI that get recreated constantly. They’re not reliable containers for stateful ViewModels. So where should ViewModels live?
The answer: inside the coordinator’s route array. This is where RouteIdentifier becomes essential:
struct RouteIdentifier<T>: Hashable {
let value: T
let id: AnyHashable
init<ID: Hashable>(value: T, id: (T) -> ID) {
self.value = value
self.id = AnyHashable(id(value))
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: RouteIdentifier<T>, rhs: RouteIdentifier<T>) -> Bool {
return lhs.id == rhs.id
}
}By wrapping ViewModels (or domain objects) in RouteIdentifier, you accomplish two things:
- Satisfy SwiftUI’s Hashable requirement for navigation routes
- Preserve ViewModels in memory for the lifetime of the screen
When you navigate to a screen, you store the ViewModel in the route:
func detail(for group: Group) {
let viewModel = GroupDetailViewModel(group: group)
routes.append(.group(.init(value: viewModel, id: \.group.id)))
}As long as that route exists in the routes array, the ViewModel stays in memory—giving you the same reliable lifecycle you had with UIKit ViewControllers. When the user navigates back and the route is popped, the ViewModel is deallocated naturally.
This solves a fundamental architectural challenge in SwiftUI: where to place stateful objects when Views themselves are transient. The coordinator’s route array becomes the source of truth for screen lifecycle, just as the UIViewController hierarchy was in UIKit.
Why the Application Layer?
You might wonder: why should the Application layer own navigation instead of features themselves? The answer lies in understanding what the Application layer uniquely knows.
Complete Navigation Hierarchy
The Application layer is responsible for creating the main user interface. This responsibility inherently provides it with complete awareness of:
- All available screens across all features
- The navigation structure and hierarchy
- Tab bar organization
- Modal presentation contexts
- Navigation stack relationships
Individual features, by design, should have a narrow view of the world—they know about their own screens and nothing more. They can’t make informed decisions about navigation flows that span multiple features or affect the broader app structure.
Cross-Feature Navigation Made Trivial
In highly modularized applications, one of the most common pain points is navigating between features. Imagine you’re in a shopping flow (Feature A) and need to show the user’s profile screen (Feature B). With traditional approaches, you face several bad options:
- Create a dependency from Feature A to Feature B (breaks modularity)
- Use a router with stringly-typed route names (loses type safety)
- Implement a messaging system or notification pattern (adds complexity)
- Duplicate the profile screen in Feature A (violates DRY)
With Application-level coordinators, this becomes trivial. The Application layer already knows about both features:
final class MainCoordinator: ShoppingCoordinator, ProfileCoordinator {
func showProfile() {
// Can show profile from shopping flow without any coupling
// between shopping and profile features
routes.append(.profile)
}
}The coordinator naturally bridges features because it lives at the composition root where all features come together. No workarounds, no compromises.
Team Independence Through Loose Coupling
This pattern shines in team environments. Different teams can work on different features completely independently:
Team A (working on Groups feature):
// Defines what navigation they need
protocol GroupsCoordinator {
func detail(for group: Group)
func showExpense(_ expense: Expense)
}Team B (working on Expenses feature):
// Defines their own navigation requirements
protocol ExpensesCoordinator {
func editExpense(_ expense: Expense)
func showParticipants()
}Integration Team (Application layer):
// Wires everything together
final class AppCoordinator: GroupsCoordinator, ExpensesCoordinator {
// Implements both protocols
// Can create flows that span both features
}Teams A and B never need to coordinate on implementation details. They define their contracts (protocols) and work independently. The integration happens once, in the Application layer. This is drastically different from traditional Coordinator patterns with “child coordinators” where parent-child relationships create tight coupling and coordination overhead.
Not Your Traditional Coordinator Pattern
It’s crucial to understand: this is not the classic “Coordinator pattern” with child coordinators and delegation chains. Those patterns often recreate the same coupling problems they’re trying to solve:
- Parent coordinators need to know about child coordinators
- Starting and stopping child coordinators becomes complex
- Memory management of coordinator hierarchies is error-prone
- Deep navigation flows require coordinator chains
Our approach is fundamentally simpler: features define protocol interfaces, the Application layer implements them. There are no child coordinators, no coordinator hierarchies, no parent-child relationships. Just clean protocol conformance at the composition root.
Key Benefits
1. Framework Independence
Features don’t depend on SwiftUI’s navigation APIs. If SwiftUI navigation becomes problematic or we need to support UIKit, we only change the Application layer. The features remain untouched.
2. Full Navigation Context
The Application layer has complete awareness of the navigation hierarchy. It knows about all screens, their relationships, and can make informed decisions about navigation flows—something individual features cannot do.
3. Clean Separation of Concerns
Navigation is a cross-cutting concern that shouldn’t live in feature modules. By delegating it to the Application layer, features stay focused on their core responsibilities: business logic and UI presentation.
4. Testability
Testing navigation becomes straightforward—create a mock coordinator, verify it’s called with the correct parameters, and you’re done. No need to test actual navigation implementation in feature tests.
5. Type Safety
Using enums for routes and modals provides compile-time safety. Impossible states are truly impossible, and refactoring is supported by the compiler.
6. Flexibility
Different features can use different UI patterns (MVVM, TCA, etc.) while sharing the same navigation abstraction. The coordinator pattern adapts to any architecture choice.
Real-World Example
In my app, the GroupsCoordinator conforms to multiple coordinator protocols—both GroupsCoordinator and GroupDetailCoordinator. This allows different screens in the same navigation hierarchy to share a single coordinator instance while maintaining their own protocol interfaces:
final class GroupsCoordinator: GroupsCoordinator, GroupDetailCoordinator {
// Implements navigation for both groups list and group detail screens
}This approach scales beautifully as the app grows. New navigation requirements are added as protocol methods, implemented in the coordinator, and features remain blissfully unaware of the implementation details.
Deep Linking: The Hidden Superpower
One of the most elegant benefits of this pattern is how naturally it handles deep linking. Since the Application layer owns navigation and has access to all routes, implementing deep links becomes surprisingly straightforward.
Consider a deep link like spendid://groups/{groupId}/expenses/{expenseId}. You want to:
- Open the groups list
- Navigate to a specific group detail
- Navigate to a specific expense
With Application-level navigation, this is trivial:
final class GroupsCoordinator: GroupsCoordinator, GroupDetailCoordinator {
func handle(deepLink: DeepLink) {
switch deepLink {
case .groupExpense(let groupId, let expenseId):
// Fetch the necessary data
let group = fetchGroup(id: groupId)
let expense = fetchExpense(id: expenseId)
// Build the navigation stack
routes.append(.group(.init(value: group, id: \.id)))
routes.append(.expense(.init(value: expense, id: \.id)))
case .group(let groupId):
let group = fetchGroup(id: groupId)
routes.append(.group(.init(value: group, id: \.id)))
}
}
}The key insight: you can programmatically build the entire navigation stack. SwiftUI’s NavigationStack accepts an array of routes, so pushing multiple screens is just appending to that array. The user sees a properly constructed navigation hierarchy with working back buttons and proper state.
Try implementing this with feature-owned navigation—you’ll quickly find yourself passing deep link data through multiple layers, coordinating between features, and managing complex state. With Application-level coordinators, it’s just another method.
This same capability extends to:
- Push notifications: Navigate to specific content when a notification is tapped
- Universal links: Handle web URLs that map to app screens
- Shortcuts: Siri shortcuts that navigate to specific app states
- Testing: Programmatically set up complex navigation states for UI tests
All of these become simple coordinator methods rather than complex navigation orchestration.
State Restoration: Never Lose Context
Here’s something users love but developers often skip: state restoration. When iOS kills your app from memory, users expect to return to exactly where they were. With traditional SwiftUI navigation, implementing this is painful—navigation state lives in multiple places, deeply nested in view hierarchies.
With coordinator-based navigation, state restoration becomes trivial. Why? Because the coordinator’s route array is already your serializable navigation state.
Saving State
When your app enters the background, simply encode the route identifiers:
final class GroupsCoordinator: GroupsCoordinator {
func saveState() -> Data? {
let routeIds = routes.compactMap { route -> String? in
switch route {
case .group(let identifier):
return "group:\(identifier.id)"
case .expense(let identifier):
return "expense:\(identifier.id)"
}
}
return try? JSONEncoder().encode(routeIds)
}
}Restoring State
On app launch, restore the navigation stack by rebuilding the routes array:
func restoreState(from data: Data) async {
guard let routeIds = try? JSONDecoder().decode([String].self, from: data) else { return }
for routeId in routeIds {
let components = routeId.split(separator: ":")
guard components.count == 2 else { continue }
switch components[0] {
case "group":
if let group = await fetchGroup(id: String(components[1])) {
routes.append(.group(.init(value: group, id: \.id)))
}
case "expense":
if let expense = await fetchExpense(id: String(components[1])) {
routes.append(.expense(.init(value: expense, id: \.id)))
}
default:
break
}
}
}SwiftUI’s NavigationStack automatically handles the actual navigation—you just provide the routes array. The user sees their full navigation history restored, with working back buttons and proper state.
Why This Matters for UX
State restoration transforms the user experience, especially for utility apps:
- Finance apps: Users return to the exact transaction or account they were viewing
- Note-taking apps: Jump back into the note being edited, even deeply nested in folders
- Shopping apps: Resume browsing at the exact product, maintaining cart context
- Task managers: Land on the specific project and task list, preserving workflow
With feature-owned navigation, implementing this requires each feature to expose its state, coordinate serialization across modules, and carefully reconstruct view hierarchies. With coordinator-based navigation, it’s just encoding and decoding an array of identifiers—the same approach you’d use for deep linking.
The pattern’s beauty is in its simplicity: one route array, one source of truth, trivial to save and restore. Users never lose context, and you didn’t need to build complex state management machinery to achieve it.
Conclusion
The Coordinator Protocol pattern isn’t just about navigation—it’s about properly separating concerns and maintaining architectural boundaries. By keeping navigation implementation details in the Application layer where they belong, features become more focused, testable, and reusable.
What makes this pattern particularly powerful is what it’s NOT:
- It’s not a hierarchy of parent and child coordinators
- It’s not complex coordinator lifecycle management
- It’s not features trying to coordinate navigation across module boundaries
Instead, it’s refreshingly simple: features declare their navigation needs through protocols, and the Application layer—which uniquely has complete context of the entire app—implements those protocols. This simplicity unlocks capabilities that are difficult or impossible with other approaches:
- Effortless cross-feature navigation
- True team independence in modular codebases
- Trivial deep linking and universal link handling
- Programmatic navigation for testing and automation
- Freedom to change navigation frameworks without touching features
The key insight is simple: features should declare what navigation they need, not how to navigate. The Application layer, with its complete understanding of the app’s structure, is the right place to handle the how.
This pattern has proven invaluable in maintaining loose coupling between modules while taking full advantage of SwiftUI’s powerful navigation APIs. It’s a perfect example of how proper abstraction can give you both flexibility and simplicity—and how thinking carefully about where responsibilities belong can eliminate entire categories of problems.
Example Implementation
You can see this pattern in action in my ModernCleanArchitectureSwiftUI repository, where I’ve explored this approach with a complete working example.