From Modular Packages to Clean Navigation: Clean Architecture & Coordinator in Modern SwiftUI
In one of my articles, we talked about splitting your app into independent SPM packages — letting dependencies flow strictly downward from Foundation to Theme, UI Controls, and Feature Modules.
That solves code duplication and broken buttons. But it brings an immediate follow-up problem:
How do Feature Packages navigate to each other without depending on each other?
If FeatureA needs to push FeatureB, but package rules say never depend sideways, how do you route without importing FeatureB into FeatureA?
If your ViewModel handles network calls, presentation state, and pushes screens onto a stack, you haven't written modern modular iOS code — you've just moved your Massive View Controller inside a shiny new SPM package.
The fix? Uniting Clean Architecture with the Coordinator & Router Pattern using SwiftUI’s NavigationStack.
The One Navigation Rule
Just like SPM, modular navigation has one rule that matters:
A Feature Package knows what data it needs to present. It NEVER knows where it sits in the app flow or what screen comes next.
The App Target imports the feature packages, resolves their dependencies, and orchestrates navigation via a central Coordinator. The feature packages simply emit intents, and the Coordinator executes the transitions.
Layer 1: Clean Architecture Inside the Feature Package
Inside your feature package (e.g., ProductFeature), keep business rules decoupled using pure Swift contracts. Zero UI imports in your Domain layer.
1. Domain Entities & Repository Contracts
Dependencies flow downward. Never sideways. Never upward. No exceptions.
// MARK: - Domain Entity
public struct Product: Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let price: Double
public init(id: String, name: String, price: Double) {
self.id = id
self.name = name
self.price = price
}
}
// MARK: - Repository Contract (Domain)
public protocol ProductRepositoryProtocol: Sendable {
func fetchProducts() async throws -> [Product]
}2. Presentation Layer (@Observable ViewModel)
Using Swift’s @Observable macro, the ViewModel manages state and delegates navigation intents using a closure.
import SwiftUI
@Observable
public final class ProductListViewModel {
public var products: [Product] = []
public var isLoading = false
private let repository: ProductRepositoryProtocol
// Navigation Intent Output Closure
private let onSelectProduct: (Product) -> Void
public init(
repository: ProductRepositoryProtocol,
onSelectProduct: @escaping (Product) -> Void
) {
self.repository = repository
self.onSelectProduct = onSelectProduct
}
@MainActor
public func loadData() async {
isLoading = true
defer { isLoading = false }
products = (try? await repository.fetchProducts()) ?? []
}
public func didTapProduct(_ product: Product) {
// Feature package emits an intent; Coordinator handles it.
onSelectProduct(product)
}
}Closure or delegate — pick based on intent count, not preference. A closure is the right call here because this ViewModel emits exactly one navigation intent. The moment a feature needs to emit three or four (select item, retry, dismiss, open settings), a ProductListViewModelDelegate protocol reads far better than four separate escaping closures threaded through the initialiser:
public protocol ProductListViewModelDelegate: AnyObject {
func productList(_ vm: ProductListViewModel, didSelect product: Product)
func productListDidRequestRetry(_ vm: ProductListViewModel)
}Both compile down to the same thing — a decoupled way for the feature to say “this happened” without knowing what happens next. Closures for one or two intents, a delegate protocol once you’re naming more than that. Don’t let closure count become the tell that a screen is doing too much.
Layer 2: The Router & Coordinator (NavigationStack)
In modern SwiftUI, NavigationStack driven by a NavigationPath gives us programmatic, type-safe routing. We place this coordinator in the App Target (or a dedicated AppNavigation package).
1. Typed App Routes
public enum AppRoute: Hashable {
case productList
case productDetail(Product)
case checkout(cartItems: [Product])
}2. The App Coordinator
The Coordinator owns the NavigationPath, injects dependencies, and builds destination views.
import SwiftUI
@Observable
public final class AppCoordinator {
public var path = NavigationPath()
private let productRepository: ProductRepositoryProtocol
public init(productRepository: ProductRepositoryProtocol) {
self.productRepository = productRepository
}
// MARK: - Navigation Triggers
public func showDetail(for product: Product) {
path.append(AppRoute.productDetail(product))
}
public func showCheckout(items: [Product]) {
path.append(AppRoute.checkout(cartItems: items))
}
public func popToRoot() {
path.removeLast(path.count)
}
// MARK: - View Factory / Router
@ViewBuilder
public func buildScreen(for route: AppRoute) -> some View {
switch route {
case .productList:
let vm = ProductListViewModel(
repository: productRepository,
onSelectProduct: { [weak self] product in
self?.showDetail(for: product)
}
)
ProductListView(viewModel: vm)
case .productDetail(let product):
ProductDetailView(
product: product,
onBuyNow: { [weak self] in
self?.showCheckout(items: [product])
}
)
case .checkout(let items):
CheckoutView(items: items)
}
}
}Layer 3: Root Entry Point
Tie everything together at the App level. The NavigationStack binds to coordinator.path, while navigationDestination renders views using the coordinator's view builder.
import SwiftUI
struct RootContainerView: View {
@State private var coordinator: AppCoordinator
init(repository: ProductRepositoryProtocol) {
_coordinator = State(wrappedValue: AppCoordinator(productRepository: repository))
}
var body: some View {
NavigationStack(path: $coordinator.path) {
coordinator.buildScreen(for: .productList)
.navigationDestination(for: AppRoute.self) { route in
coordinator.buildScreen(for: route)
}
}
}
}
@main
struct StoreApp: App {
var body: some Scene {
WindowGroup {
// Inject production dependencies at launch
RootContainerView(repository: ProductionProductRepository())
}
}
}Don’t let the Coordinator become the new god object
There’s one failure mode this pattern doesn’t protect you from automatically: a single AppCoordinator that ends up owning onboarding, the store, settings, and auth all at once. That's the same disease as the Massive View Controller you started this whole refactor to avoid — you've just renamed it.
Get Mok5ha G’s stories in your inbox
Join Medium for free to get updates from this writer.
Scope a coordinator to a flow, not to the app. A StoreCoordinator, an OnboardingCoordinator, and a SettingsCoordinator each own their own NavigationPath and their own route enum. A thin AppCoordinator (or the root NavigationStack itself, for simple cases) decides which flow-level coordinator is active — it doesn't know how checkout navigates internally, only that "the store flow is showing."
If flows need to hand off to each other — checkout finishing and returning to onboarding, say — that handoff is itself an intent bubbled up to whatever owns both, the same way a feature package emits an intent to the App Target. The rule doesn’t change, it just applies one level up: a flow coordinator knows what happened inside its flow, never what flow comes next.
Why Senior Devs Love This Setup
When you combine SPM package isolation with Clean Architecture and Coordinators, three things happen:
- Zero Sideways Package Imports:
FeatureAnever needs to importFeatureBjust to navigate to it. The App target imports both and connects them cleanly in the Coordinator. - Instant SwiftUI Canvas Previews: Because ViewModels receive plain navigation closures, you can instantiate any View inside an SPM package preview instantly using dummy closures without mocking an entire navigation stack.
- 100% Testable Flow Logic: You can write unit tests for your
AppCoordinatorby asserting changes on itspathproperty when action closures trigger — no UI test overhead needed.
Good iOS architecture isn’t about writing extra abstractions for fun — it’s about making sure that when your app scales to 15 engineers and 60 feature modules, adding screen #61 feels just as fast as screen #1.








