DEV Community

Konstantin Shkurko
Konstantin Shkurko

Posted on

Coordinator Pattern: Taking Control of the Flow

This article is a logical continuation of our dive into architecture. If in the first part we brought order inside the "black box" called ViewModel, here we'll step beyond its boundaries. You'll learn how to rip out navigation logic from ViewControllers and ViewModels, why prepare(for:sender:) is an architectural dead end, and how to build a navigation system that won't turn your project into spaghetti when you add the tenth screen. We'll break down the concept of Child Coordinators, solve memory leak problems, and discuss whether this pattern survived in the SwiftUI era.

If MVVM is responsible for what happens inside a screen, then Coordinator is the answer to the question "where are we going next?".

In Apple's standard approach, navigation is baked right into UIViewController. This is convenient for small demo projects, but in real production it leads to controllers knowing way too much about each other. When LoginViewController creates HomeViewController, you get tight coupling. Try changing the flow later or reusing LoginViewController somewhere else - and you'll understand why this is a bad idea.

I believe a controller should be selfish. It shouldn't know where it came from or where it's going. Its job is to display data and report that the user tapped a button. That's it.

The Problem: Hardcoded Navigation

Let's be honest: who among us hasn't written self.navigationController?.pushViewController(nextVC, animated: true) directly in a button tap handler?

Problems start when:

  1. You need to change the logic: If the user is not authorized, we go to one screen, if authorized but hasn't filled out their profile - to another. Writing this inside the controller means bloating its logic.
  2. Dependency Injection: To create the next controller, the current one needs to pass dependencies (database, API client) into it. Where does the current controller get them from? Store them "just in case"? That's garbage in the code.
  3. Deep Linking: Try implementing a transition to a deeply nested screen from a Push notification if all navigation is baked into controllers. It'll be a chain of if-else statements and hacks.

The Basic Implementation

A coordinator is a simple object that encapsulates the logic of creating controllers and managing their lifecycle. Let's start with a basic protocol.

Swift

protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }

    func start()
}
Enter fullscreen mode Exit fullscreen mode

Why do we need childCoordinators? This is a critically important point for memory management. If you just create a coordinator and don't save a reference to it, it'll die right after exiting the method. The childCoordinators array keeps them "alive" while working with a specific flow.

The "Start" Method

The start() method is the entry point. No magic, just creating the needed screen and displaying it.

Swift

final class AuthCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController

    // Delegate for communication with parent (e.g., AppCoordinator)
    weak var parentCoordinator: MainCoordinator?

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    func start() {
        let viewModel = LoginViewModel()
        // Here we link the VM and Coordinator
        viewModel.coordinator = self 
        let viewController = LoginViewController(viewModel: viewModel)
        navigationController.pushViewController(viewController, animated: true)
    }

    func showRegistration() {
        let regVC = RegistrationViewController()
        navigationController.pushViewController(regVC, animated: true)
    }
}
Enter fullscreen mode Exit fullscreen mode

Communication: ViewModel to Coordinator

How does ViewModel tell the coordinator "time to navigate"? I prefer two approaches:

  1. Closures: Simple and effective for small projects.
  2. Delegates: More structured and familiar for iOS development.

Personally, I've been leaning toward closures lately because it reduces boilerplate code. But if the flow is complex and there are many events, delegates look cleaner.

Swift

// Closure approach in ViewModel
final class LoginViewModel {
    var onLoginSuccess: (() -> Void)?
    var onForgotPassword: (() -> Void)?

    func loginPressed() {
        // ... API call
        onLoginSuccess?()
    }
}

// In Coordinator
func start() {
    let vm = LoginViewModel()
    vm.onLoginSuccess = { [weak self] in
        self?.showMainFlow()
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The "Back Button" Nightmare

The biggest pain in UIKit when using coordinators is the system back button. The user taps it, the controller gets removed from memory, and your coordinator is still hanging in the childCoordinators array. Congratulations, you have a memory leak.

There are three ways to solve this:

  1. Subscribe to UINavigationControllerDelegate: The didShow method lets you check which controller was removed, and if it was "ours", remove the coordinator too.
  2. Custom back button: Brutal, inconvenient, breaks native UX (swipe to back). Don't recommend.
  3. Using a wrapper over UIViewController: Overriding viewDidDisappear. Also has nuances.

I usually use the first option. It requires a bit more code, but it works reliably and preserves native system behavior.

Parent and Child: The Hierarchy

Imagine the app as a tree. At the root is AppCoordinator. It decides whether to launch AuthCoordinator or MainCoordinator.

When AuthCoordinator finishes its work (user logged in), it must notify the parent. The parent removes it from childCoordinators, clears the stack, and launches the next flow.

Swift

func didFinishAuth() {
    // Remove from children list
    childCoordinators.removeAll { $0 === authCoordinator }
    // Launch main flow
    showMainTabbar()
}
Enter fullscreen mode Exit fullscreen mode

DI: Dependency Injection on Steroids

Coordinator is the perfect place for dependency injection. Instead of passing NetworkService through five controllers, you keep it in the coordinator (or get it from a DI container) and pass it only to the ViewModel that actually needs it.

This makes the code insanely easy to test. You can create a coordinator with MockNetworkService and verify that it handles errors correctly.

SwiftUI: Is the Coordinator Dead?

With the release of NavigationStack in iOS 16 and NavigationPath, Apple gave us tools for managing navigation at the state level. Does this mean the death of the pattern?

Yes and no. In SwiftUI, the classic coordinator that holds UINavigationController is no longer needed. But the concept of separating navigation logic from View hasn't gone anywhere. Now we often call it Router.

Instead of manipulating controllers, Router manages NavigationPath (an array of data representing the stack).

Swift

class Router: ObservableObject {
    @Published var path = NavigationPath()

    func navigateToDetails(product: Product) {
        path.append(product)
    }
}
Enter fullscreen mode Exit fullscreen mode

It's the same coordinator, just dressed in modern SwiftUI clothes.

Anti-patterns to Avoid

  1. God Coordinator: Don't try to cram navigation for the entire app into one file. Divide into logical blocks (Auth, Profile, Settings).
  2. Passing ViewControllers: Coordinator should never return UIViewController to the outside. It should show or push it itself.
  3. Strong References to Parents: Always use weak for references to parent coordinators, otherwise you'll create a retain cycle, and memory will never be released.

To sum up: Coordinator isn't just an extra layer of code. It's freedom. Freedom to swap screens in five minutes, freedom to test navigation separately from UI, and freedom to never see prepare(for:segue:) in your nightmares. Yes, it requires discipline and writing slightly more protocols, but in a project that lives longer than a couple of months, it pays off handsomely.

Top comments (0)