The Coordinator pattern in Swift, without the ceremony
Navigation logic doesn't belong in view controllers. Here's the version of the Coordinator pattern I actually use in production - small, boring, and easy to delete.
Every iOS codebase eventually hits the same wall: a view controller that knows too much. It presents the next screen, decides which storyboard to load, passes dependencies along, and suddenly your "login screen" is also your app's navigation brain.
The Coordinator pattern fixes exactly this: view controllers show things, coordinators decide what happens next. The idea has been around since Soroush Khanlou's 2015 talk, but many implementations drown in protocols and generics. Mine fits in a few dozen lines.
The core protocol
A coordinator needs to do exactly one thing - start:
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
func start()
}
childCoordinators exists only to keep child coordinators alive - nothing retains them otherwise. That's it. No associated types, no generic Route enums until a project genuinely needs them.
An app with a tab bar
The app coordinator owns the window and builds the tab structure. Each tab gets its own coordinator with its own navigation controller:
final class AppCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
private let window: UIWindow
private let tabBarController = UITabBarController()
init(window: UIWindow) {
self.window = window
}
func start() {
let home = HomeCoordinator()
let settings = SettingsCoordinator()
childCoordinators = [home, settings]
home.start()
settings.start()
tabBarController.viewControllers = [
home.navigationController,
settings.navigationController
]
window.rootViewController = tabBarController
window.makeKeyAndVisible()
}
}
A tab coordinator pushes screens and handles their outcomes:
final class HomeCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
let navigationController = UINavigationController()
func start() {
let vc = HomeViewController.instantiate()
vc.onSelectItem = { [weak self] item in
self?.showDetail(for: item)
}
navigationController.viewControllers = [vc]
}
private func showDetail(for item: Item) {
let vc = DetailViewController.instantiate()
vc.item = item
navigationController.pushViewController(vc, animated: true)
}
}
Notice the view controller exposes a closure (onSelectItem) instead of importing the coordinator. The dependency points one way: coordinators know about view controllers, never the reverse. That's the property that makes screens reusable and testable.
What this buys you
- Reusable screens. A screen that doesn't know where it leads can appear in any flow - onboarding, settings, a deep link.
- Testable flows. You can drive a whole flow in a unit test by calling closures, no UI test rig needed.
- One place to look. "Where do we go after login?" has exactly one answer, in exactly one file.
What to avoid
The pattern fails when people over-abstract it. Signs you've gone too far: a Coordinator base class with fifteen overridable methods, generic routers wrapping UINavigationController's entire API, or a Route enum with ninety cases. Start with the minimal version above; add structure only when a real flow demands it.
A complete runnable example - tab bar controller, storyboards, child coordinators - is in my coordinators repo on GitHub. Clone it, run it, delete what you don't need.