Lesson 02 / 19
The Coordinator Pattern
Handing navigation to a coordinator separate from the views, and two checks that answer differently to a fault that opens the wrong screen after payment.
Published Updated
In this lesson
- Describes what moving the navigation decision from the view controller to the coordinator changes
- Measures that a check looking at the view model's state stays blind to a navigation fault
- Shows that a check looking at the route sequence catches the same fault
- Explains why a check that looks only at the final screen is not enough when the fault is in an intermediate step
Contents
In the first lesson we separated the calculation from the screen and saw that six checks produced four separations. Once the calculation is separated, the screen keeps another responsibility: deciding which screen comes next. The cart screen opens the address screen, the address screen opens the payment screen, the payment screen opens the confirmation screen. If every view controller knows the next one, the flow cannot be read in one place, and changing one link of the flow touches three screens.
The coordinator pattern takes this decision away from the screens and gives it to a separate object. The screen only says “payment finished”; the coordinator picks the next screen. This lesson’s question is the course’s question: does this check tell the correct version from the faulty one? The fault is this: when payment completes, the coordinator sends the user back to the cart instead of opening the confirmation screen. The core comes in verbatim, as in the first lesson.
// Separation.swift — classifies whether a check tells the correct version from the faulty one
struct Contrast<Output: CustomStringConvertible> {
let label: String
let correct: Output
let faulty: Output
let passesCorrect: Bool
let passesFaulty: Bool
var separation: String {
switch (passesCorrect, passesFaulty) {
case (true, false): return "separating"
case (true, true): return "blind"
case (false, false): return "noisy"
case (false, true): return "inverted"
}
}
func describe() -> String {
let onCorrect = passesCorrect ? "pass" : "fail"
let onFaulty = passesFaulty ? "pass" : "fail"
print("\(label) | correct=\(correct)/\(onCorrect) faulty=\(faulty)/\(onFaulty) -> \(separation)")
return separation
}
}
func contrast<Output>(_ label: String, correct: Output, faulty: Output, check: (Output) -> Bool) -> Contrast<Output> {
Contrast(label: label, correct: correct, faulty: faulty, passesCorrect: check(correct), passesFaulty: check(faulty))
}
func countSeparations(_ results: [String]) {
for name in ["separating", "blind", "noisy", "inverted"] {
print("\(name): \(results.filter { $0 == name }.count)")
}
}
The core is the same as the core in the first lesson; it is not redefined in this lesson, only used. The separation names do not change: separating, blind, noisy, inverted.
Taking Navigation Out of the Screen
In a flow without a coordinator, the navigation decision lives inside the view controller. The payment screen builds the confirmation screen itself and opens it itself.
import UIKit
// Navigation inside the screen — KAYNAK
final class PaymentViewController: UIViewController {
func paymentDidFinish() {
let next = ConfirmationViewController()
navigationController?.pushViewController(next, animated: true)
}
}
This screen knows the confirmation screen. When you want to use the payment screen in another flow, for example renewing a subscription, the confirmation screen opens there too. Checking the flow also depends on the screen: to see what opens after payment, you have to build a navigation controller and a payment screen.
With a coordinator, the same decision lives in a separate object and the screen only reports an event.
import UIKit
// Navigation in the coordinator — KAYNAK
final class CheckoutFlowCoordinator {
private let navigation: UINavigationController
init(navigation: UINavigationController) {
self.navigation = navigation
}
func paymentCompleted() {
navigation.pushViewController(ConfirmationViewController(), animated: true)
}
}
Neither fragment is run; the navigation controller depends on the platform’s lifecycle and the lesson claims no output for it. What sets the two apart is structure. In the second, the payment screen does not know the confirmation screen; the link between the screen and the flow is an event name. The whole flow reads in one class, and what opens after payment is visible in one function. This lesson’s measurement looks at that function.
The Coordinator’s Model
The platform’s navigation is not measured, but the decision the coordinator makes is itself pure logic: an event arrives and the route sequence changes. The model builds this in three parts: route names, the payment screen’s view model, and the coordinator that holds the route sequence. The fault is inside the coordinator: backToCart drops the sequence back to the cart after payment. extraNotice is the second fault, used in the bounding measurement. Checkout.swift is a helper file and prints no output.
// Checkout.swift — model of the checkout flow: view model and coordinator
enum Route: String {
case cart, address, payment, notice, confirmation
}
enum Fault {
case none, backToCart, extraNotice
}
struct PaymentViewModel {
private(set) var done = false
mutating func complete() { done = true }
}
struct CheckoutCoordinator {
private(set) var path: [Route] = [.cart, .address, .payment]
let fault: Fault
mutating func paymentCompleted() {
switch fault {
case .none: path.append(.confirmation)
case .backToCart: path = [.cart]
case .extraNotice: path.append(contentsOf: [.notice, .confirmation])
}
}
}
func runCheckout(_ fault: Fault) -> (done: Bool, path: [Route]) {
var model = PaymentViewModel()
var coordinator = CheckoutCoordinator(fault: fault)
model.complete()
coordinator.paymentCompleted()
return (model.done, coordinator.path)
}
func trail(_ path: [Route]) -> String { path.map { $0.rawValue }.joined(separator: ">") }
func finalRoute(_ path: [Route]) -> String { path.last?.rawValue ?? "none" }
runCheckout produces two things side by side: the view model’s “payment completed” state and the coordinator’s route sequence. The view model knows payment finished, but it does not know which screen opens afterward; that is the coordinator’s job. trail turns the sequence into a readable string, and finalRoute gives only the last route.
Four checks are applied to the same two versions. The correct version is runCheckout(.none), the faulty version is runCheckout(.backToCart).
// main.swift — which screen opens after payment
let good = runCheckout(.none)
let bad = runCheckout(.backToCart)
let paid = contrast("payment-completed", correct: good.done, faulty: bad.done) { $0 == true }.describe()
let last = contrast("last-screen", correct: finalRoute(good.path), faulty: finalRoute(bad.path)) { $0 == "confirmation" }.describe()
let first = contrast("route-start", correct: good.path.first?.rawValue ?? "none", faulty: bad.path.first?.rawValue ?? "none") { $0 == "cart" }.describe()
let whole = contrast("whole-route", correct: trail(good.path), faulty: trail(bad.path)) { $0 == "cart>address>payment>confirmation" }.describe()
countSeparations([paid, last, first, whole])
payment-completed | correct=true/pass faulty=true/pass -> blind last-screen | correct=confirmation/pass faulty=cart/fail -> separating route-start | correct=cart/pass faulty=cart/pass -> blind whole-route | correct=cart>address>payment>confirmation/pass faulty=cart/fail -> separating separating: 2 blind: 2 noisy: 0 inverted: 0
The output was run three times in the sandbox on Swift 6.4 and is identical in all three. Of the four checks, two came out separating and two blind.
payment-completed looks at the view model’s state. Payment really did complete, so the state is true on both versions. The fault is in the screen the coordinator picks, and the view model takes no part in that decision. The check is blind, and it shows a consequence of separating the coordinator: a test that looks at a screen’s value cannot see a navigation fault, because navigation is no longer the screen’s value. If the fault moved to another object, the check must move to that object too.
route-start is also blind. The faulty version makes the sequence [.cart], and the first element of the sequence is still cart. The check looks at something correct, but at something the fault does not touch.
last-screen looks at the last element of the route sequence. It comes out confirmation on the correct version and cart on the faulty version; the second does not meet the expectation. The check is separating. whole-route compares the entire sequence with the expected route and separates in the same way. What the two have in common is that both look at the route the coordinator produces. Handing navigation to the coordinator therefore changes not only the design but also the target of the check.
What the Coordinator Solves and What It Does Not
The coordinator’s gain shows in three places. The first is readability: the whole flow sits in one class. None of the screens knows the screen after it, so a screen can be reused in another flow. The second is changeability: if you want a thank-you screen instead of the confirmation screen after payment, only paymentCompleted changes. The third is checkability, and it is the subject of this lesson: once the route decision comes down to pure logic, the same check is applied to two versions without building a screen.
It has a cost too. The coordinator is an object that holds a reference to the screen, and the screen reports back to the coordinator. If the two hold each other with strong references, neither is ever released; this is a retain cycle and leads to a memory leak. The remedy is for the screen to hold the coordinator with a weak reference. The memory side will be covered in the Memory Leak Detection lesson; here, just note this: the coordinator’s model does not show this cycle, because the model holds no screen. This is something the model deliberately ignores.
Nested flows are a cost as well. If the payment flow is a child coordinator, it has to report its completion to the parent coordinator. If the notification is lost, the parent coordinator never learns that the child flow finished and the screen stays open. That behavior is also outside the model; the model looks at one event of one coordinator.
What the Route Sequence Does Not Guarantee
The check that looks at the whole route was the strongest check in this lesson, but being strong does not mean it catches everything. Even when the route sequence is correct, the real navigation controller can open a different screen: the coordinator produces a route called confirmation, but the code that builds the screen for that route may return the wrong screen. The route sequence tests the decision layer; the screen-building code tests the application layer. The mapping between them calls for a separate check, and because that check depends on the platform, it stays KAYNAK.
This is the same limit as in the first lesson: a check catches only the fault it was set up for. The whole-route check caught the fault of opening the wrong route after payment, but it does not catch a fault that attaches the wrong screen to the right route. A check set up for that fault has to look at the type of the screen, and that lies in the layer that cannot be measured. So the lesson does not say “navigation was verified”; it says “the navigation decision was verified”.
Another limit is the event itself. The model assumes the paymentCompleted function is called. If the payment screen never reports the event, the coordinator does not run, the route sequence stays at the payment step, and the whole-route check fails, that is, it catches the fault as separating. But this says nothing about where the fault is: the check only knows the sequence is wrong; it does not know whether the wrongness is in the screen or in the coordinator. The question of why is not the check’s job; finding the location of the fault is a separate step and requires looking at the source code.
Assumptions
AR1. The route sequence is the order of screens the coordinator will pass to the navigation controller. This lesson does not measure whether a real navigation controller’s stack behaves exactly like this sequence; the navigation controller stays KAYNAK.
AR2. What the user sees after payment, the animation, and the transition time do not enter the model. The model says only which route opens.
AR3. The view model’s “payment completed” state and the coordinator’s event function are called at the same moment. In a real app a network response may come in between; that dependency is covered in the fourth lesson.
The Bounding Measurement
The same two checks can answer differently when the fault is somewhere else. This time the fault does not break the final screen; it breaks an intermediate step: after payment, before the confirmation screen, an unneeded notice screen opens. The correct version is runCheckout(.none), the faulty version is runCheckout(.extraNotice).
// main.swift — the fault is in an intermediate step
let goodNotice = runCheckout(.none)
let badNotice = runCheckout(.extraNotice)
let lastOnly = contrast("last-screen-notice", correct: finalRoute(goodNotice.path), faulty: finalRoute(badNotice.path)) { $0 == "confirmation" }.describe()
let wholePath = contrast("whole-route-notice", correct: trail(goodNotice.path), faulty: trail(badNotice.path)) { $0 == "cart>address>payment>confirmation" }.describe()
countSeparations([lastOnly, wholePath])
last-screen-notice | correct=confirmation/pass faulty=confirmation/pass -> blind whole-route-notice | correct=cart>address>payment>confirmation/pass faulty=cart>address>payment>notice>confirmation/fail -> separating separating: 1 blind: 1 noisy: 0 inverted: 0
The output is identical across three runs. The last-screen check, which was separating in the measurement above, is blind to this fault even though it is built on the same logic: both versions end with confirmation. The only check that sees the fault is the one that looks at the whole route. The check itself did not change; what changed is where the fault is. If a flow test checks only the end of the flow, every flow with a correct end passes green; unneeded or missing steps in between are never seen.
Summary
- The coordinator takes the navigation decision from the view controllers and gives it to a single object; the screen only reports an event.
- When the place of the decision changes, the target of the check changes too: a navigation fault shows in the coordinator’s route sequence, not in the view model’s state.
- A check that looks at the view model’s “payment completed” state is blind to a faulty coordinator.
- Checks that look at the final screen and at the whole route catch the same fault: of four checks, two are separating and two are blind.
- When the fault moves to an intermediate step, the check that looks only at the final screen stays blind; the check that looks at the whole route stays separating.
- This lesson’s separation: blind.
Next Step
We separated navigation and the calculation; what remains is the state the screen depends on. In the third lesson, Unidirectional Data Flow will keep state in one place and let change arrive only through an action. There the fault will be in a reducer: a remove-product action will delete the line instead of decreasing the quantity, and a check that compares the resulting state for the same sequence of actions will catch the fault this time.
To keep your progress and take notes, Log in
My notes
Log in to take notes.