← All topics/Architecture & design patterns

Practical interview questions

Scenario-style prompts with sample answer outlines. Focus is on how you would design and reason in real codebases.

Question 2

Coordinators & navigation ownership

You’re hitting retain cycles and inconsistent back-stack behavior when view controllers push each other. How does a coordinator style flow help, and what are the tradeoffs?

Follow-ups

  • How does this interact with deep links or tab bars?

Answer outline

A coordinator (or router) owns the navigation stack and child flow: it creates VCs/Views, injects dependencies, and responds to events.

View controllers stop knowing how the next screen is constructed, reducing tight coupling and duplicate init spaghetti.

Practically you fix: who presents whom, modal vs push rules, and memory ownership.

Deeplinks become: parse URL → ask the root coordinator to switch tab and push a stack.

The main tradeoff is structure vs ceremony: coordinators make navigation explicit and testable, but they add more files, more delegation, and another place to look when debugging a flow.

They are worth it when flows are reused, deep-linked, split across tabs, or hard to reason about from a single view controller. For a one-screen feature, a coordinator can be overkill.

Keep the pattern lightweight: use one coordinator per feature or flow, keep route enums small, release child coordinators when flows finish, and test important navigation paths rather than every push.

Principles

  • The coordinator owns the navigation controller — view controllers never push or present themselves.
  • Child coordinators report up via closure or delegate; the parent releases the child on completion.
  • Store children in a childCoordinators array on the parent and remove on onComplete to prevent leaks.
  • Avoid singleton coordinators; scope them to a window scene, tab, or feature flow.

Routes must be Hashable for NavigationStack; the coordinator owns the path and exposes navigate/complete rather than letting views touch the array directly:

Route enum and coordinator
enum AuthRoute: Hashable {
    case forgotPassword
    case register
}

@Observable
final class AuthCoordinator {
    var path: [AuthRoute] = []
    var onComplete: (() -> Void)?

    func navigate(to route: AuthRoute) {
        path.append(route)
    }

    func complete() {
        onComplete?()
    }
}

The flow view binds NavigationStack to the coordinator path and maps each route to its destination view:

Flow view — NavigationStack bound to coordinator path
struct AuthFlow: View {
    let coordinator: AuthCoordinator

    var body: some View {
        NavigationStack(path: Bindable(coordinator).path) {
            LoginView(coordinator: coordinator)
                .navigationDestination(for: AuthRoute.self) { route in
                    switch route {
                    case .forgotPassword:
                        ForgotPasswordView(coordinator: coordinator)
                    case .register:
                        RegisterView(coordinator: coordinator)
                    }
                }
        }
    }
}

Views hold a plain (strong) reference to the coordinator and call its methods — no weak reference needed since the flow view owns the coordinator lifetime:

Child view — calls coordinator, never mutates path directly
struct LoginView: View {
    let coordinator: AuthCoordinator

    var body: some View {
        VStack(spacing: 16) {
            Button("Forgot password?") {
                coordinator.navigate(to: .forgotPassword)
            }
            Button("Sign in") {
                coordinator.complete()  // bubbles up to parent
            }
        }
        .navigationTitle("Sign In")
    }
}

The app coordinator holds the active child coordinator; setting it to nil releases the flow and SwiftUI swaps to the next screen:

Parent coordinator — creates child, releases on completion
@Observable
final class AppCoordinator {
    private(set) var authCoordinator: AuthCoordinator?
    private(set) var isAuthenticated = false

    init() { showAuth() }

    private func showAuth() {
        let child = AuthCoordinator()
        child.onComplete = { [weak self] in
            self?.authCoordinator = nil       // releases child
            self?.isAuthenticated = true
        }
        authCoordinator = child
    }
}

struct AppView: View {
    @State private var coordinator = AppCoordinator()

    var body: some View {
        if let auth = coordinator.authCoordinator {
            AuthFlow(coordinator: auth)
        } else {
            MainView()
        }
    }
}

Follow-up angles

  • Tab bar: app coordinator owns tab coordinators; deep link selects tab then delegates to feature coordinator.
  • Don’t let every VC reach UIApplication.shared—that hides testability and multi-window bugs.