~13 min · swift, protocols, adapter-pattern, architecture, maps
Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
One root, three children
On Pathfinder's first day, before a single route had been driven, Dad gave the rule that shaped everything after it: design the map as an adapter. Pathfinder talks to Apple Maps, to Kakao Maps and to Google's Navigation SDK, and each of those has its own idea of what a place is, what a route is, and who gets to say the turns out loud. The app's answer is one protocol, RouteProvider, with every map stack as a child of it. Apple's child shipped first and is the canonical one. Kakao covers Korea. Google covers the rest of the world.
The root asks for very little: a stable id, a displayName for Dad, a traits value, and three verbs. search(_:near:limit:) finds places, routes(from:heading:to:options:) finds routes, best first, and travelTime(from:to:options:) is the mid-drive ETA refresh. That last one takes the drive's own options on purpose, because, as the comment puts it, "a time asked without them is the time of another road". Everything comes back as neutral values, Place and NavRoute. Guidance, cameras, the voice, Pippa's report and every screen know this protocol and those values, and nothing else.
The invariants that keep it an adapter
The root is never edited to fit one child. A new provider pays its own translation cost. Apple's step words describe the maneuver at the end of a step, so the Apple child pins that point into NavStep.maneuverAlong, and guidance never has to learn Apple's convention.
Differences are declared, never asked by name. A child says what it can do in ProviderTraits; consumers read the traits. The code keeps one exception in plain sight: search asks selected.id == "apple" to try cwkPippa's Apple door first, and lesson 2 says why.
What a stack rendered stays with it. A destination does not. A search's result list and a route's line and words belong to the stack that produced them. Where Dad wants to go is a name and a coordinate, and every stack routes to it.
Four traits
ProviderTraits has four fields, and each one exists because a consumer needed to know it:
Trait
Question it answers
Apple
Kakao
Google (Navigation SDK)
guide
Whose engine guides the trip?
Pathfinder's
Pathfinder's
Google's own (Pippa still speaks the turns from its feed)
routeIsPortable
May the line be drawn on another map?
yes
no
no
regions
Where does it route?
Korea, overseas
Korea
overseas
modes
By car, on foot?
both
both
both
The default in the root's extension is Apple's shape: "everywhere, by car and on foot, guided by Pathfinder". Apple's child declares nothing and inherits it. Kakao narrows its region and keeps its line on its own map. Google says the most: its own engine guides, and its route never leaves its own map.
Now watch the consumers. The watch draws on Apple's map, so the phone sends a route's line to the wrist only when routeIsPortable is true; every stack still sends the turns. The mode picker shows traits.modes(in: region), which is empty for a region the stack does not cover. Declaring guide == .provider is a promise, and keeping it is a second job: a child whose own engine guides also conforms to SelfGuidingProvider and makes the trip itself. Starting guidance reads both. First the trait: a stack that declares .provider without the conformance, like Google's walking previews through cwkPippa's door, is refused with the reason on screen (its route is a preview), and CarPlay refuses any .provider stack. Then the conformance: a SelfGuidingProvider makes the trip (provider as? any SelfGuidingProvider), and every other route goes to Pathfinder's NavigationSession. Not one of those lines names a provider. Add a fourth stack tomorrow and the watch, the picker and the guidance switch need no edit; the new child fills in four fields, and brings SelfGuidingProvider along if its own engine guides.
Declare, don't ask. When a consumer needs to know something about a provider, that something becomes a field on the root's traits. The day a screen branches on a provider's name, the next provider costs a screen edit, and the adapter has quietly become a switch statement.
A destination is a value
Place carries a providerID: which stack found it, or pippa for a destination Pippa handed over. That field is provenance, never a gate. It used to be a gate, and lesson three tells you what that cost. Today a saved place, Pippa's handoff, a pathfinder:// link and a pin Dad drops with a long press are all a name and a coordinate. A saved place may also keep the finder's own id as placeRef, so a stack that knows that id (Google) routes to the place itself instead of only to its coordinate. Extra knowledge rides along; it never decides who may route.
The brain has no hands
The protocol lives in PathfinderKit, a Swift package whose main target is the navigation brain: geometry, the route model, the guidance engine, speed cameras, voice chunking, the shape of Pippa's report, the drive log. It imports Foundation (and CryptoKit, to name cached voice chunks) and nothing else: no MapKit, no Core Location, no audio, no network. Apple's child lives in a sibling target, PathfinderAppleMaps, and the child that asks cwkPippa's door lives in PathfinderRemoteMaps. Google's child lives in the app itself, because no package test can link the Navigation SDK. That split is why every rule deciding what Dad hears runs under swift test on a Mac in seconds, with no phone and no map key.
Code
PathfinderKit, Route/RouteProvider.swift: the root and its default traits (verbatim excerpt)·swift
public protocol RouteProvider: Sendable {
/// Stable id recorded on every place and route it produces (`apple`).
var id: String { get }
/// The name shown to Dad when he picks or is told about a provider.
var displayName: String { get }
/// Where it routes, by which modes, and who guides.
var traits: ProviderTraits { get }
/// Places matching `query`, nearest to `near` first when given.
func search(_ query: String, near: Coordinate?, limit: Int) async throws -> [Place]
/// Driving routes from `origin` to `destination`, best first. `heading` is
/// the car's current course, when known, so a reroute does not begin with a
/// U-turn the car cannot make.
func routes(from origin: Coordinate, heading: Double?, to destination: Place, options: RouteOptions)
async throws -> [NavRoute]
/// A fresh travel time from `origin` to `destination` (traffic-aware when
/// the provider has traffic), for keeping the ETA honest mid-drive. It is
/// asked with the drive's own `options` (its mode, the roads Dad told it to
/// stay off): a time asked without them is the time of another road.
func travelTime(from origin: Coordinate, to destination: Place, options: RouteOptions) async throws
-> TimeInterval?
}
extension RouteProvider {
/// Apple's shape: everywhere, by car and on foot, guided by Pathfinder.
public var traits: ProviderTraits { ProviderTraits() }
}
/// What a stack can do and how it guides. Each child of `RouteProvider`
/// declares its own; consumers ask the root and never name a provider.
public struct ProviderTraits: Equatable, Sendable {
public enum Guide: Equatable, Sendable {
/// Pathfinder's engine guides along the provider's route.
case pathfinder
/// The provider's own engine guides and draws (Google's Navigation SDK):
/// its route has no steps for Pathfinder to read, and what Pippa says comes
/// from the provider's turn feed.
case provider
}
public var guide: Guide
/// The route's geometry may be drawn on a map that is not the provider's
/// (the watch draws on Apple's).
public var routeIsPortable: Bool
public var regions: Set<MapRegion>
public var modes: Set<TravelMode>
public init(
guide: Guide = .pathfinder, routeIsPortable: Bool = true, regions: Set<MapRegion> = [.korea, .overseas],
modes: Set<TravelMode> = [.driving, .walking]
) {
self.guide = guide
self.routeIsPortable = routeIsPortable
self.regions = regions
self.modes = modes
}
/// The travel modes offered in `region`, car first.
public func modes(in region: MapRegion) -> [TravelMode] {
guard regions.contains(region) else { return [] }
return TravelMode.allCases.filter(modes.contains)
}
}
A minimal sketch: three children, consumers that read only traits, one portable destination (compiled with swiftc and run)·swift
// A minimal sketch of "the map is an adapter": one root protocol, children
// that declare what they can do, and consumers that only ever ask the root.
struct Coordinate: Hashable { var lat: Double; var lon: Double }
enum Region: Hashable { case korea, overseas }
enum Mode: String, CaseIterable { case driving, walking }
struct Traits {
enum Guide { case pathfinder, provider }
var guide: Guide = .pathfinder
var routeIsPortable = true
var regions: Set<Region> = [.korea, .overseas]
var modes: Set<Mode> = [.driving, .walking]
func modes(in region: Region) -> [Mode] {
guard regions.contains(region) else { return [] }
return Mode.allCases.filter(modes.contains)
}
}
/// Where Dad wants to go: a name and a coordinate. No provider owns it.
struct Destination { var name: String; var coordinate: Coordinate; var foundBy: String }
/// What a provider rendered: it stays with that provider.
struct Route { var providerID: String; var to: Destination; var line: [Coordinate] }
protocol RouteProvider {
var id: String { get }
var traits: Traits { get }
func route(to destination: Destination) -> Route
}
extension RouteProvider {
var traits: Traits { Traits() } // the root's default is Apple's shape
func route(to destination: Destination) -> Route {
Route(providerID: id, to: destination, line: [destination.coordinate])
}
}
struct Apple: RouteProvider { let id = "apple" }
struct Kakao: RouteProvider {
let id = "kakao"
var traits: Traits { Traits(routeIsPortable: false, regions: [.korea]) }
}
struct Google: RouteProvider {
let id = "google"
var traits: Traits { Traits(guide: .provider, routeIsPortable: false, regions: [.overseas]) }
}
// Consumers: none of them names a provider.
func lineForTheWatch(_ route: Route, from provider: any RouteProvider) -> [Coordinate]? {
provider.traits.routeIsPortable ? route.line : nil
}
func whoGuides(_ provider: any RouteProvider) -> String {
provider.traits.guide == .pathfinder ? "Pathfinder's engine" : "its own engine"
}
// A public landmark (Seoul City Hall), saved after Apple found it.
let home = Destination(name: "Home", coordinate: Coordinate(lat: 37.5665, lon: 126.9780), foundBy: "apple")
for provider in [Apple(), Kakao(), Google()] as [any RouteProvider] {
let route = provider.route(to: home)
let korea = provider.traits.modes(in: .korea).map(\.rawValue)
let abroad = provider.traits.modes(in: .overseas).map(\.rawValue)
print(provider.id, "| Korea:", korea, "| abroad:", abroad, "| guided by", whoGuides(provider),
"| watch line:", lineForTheWatch(route, from: provider) != nil, "| routes to", route.to.name)
}
// apple | Korea: ["driving", "walking"] | abroad: ["driving", "walking"] | guided by Pathfinder's engine | watch line: true | routes to Home
// kakao | Korea: ["driving", "walking"] | abroad: [] | guided by Pathfinder's engine | watch line: false | routes to Home
// google | Korea: [] | abroad: ["driving", "walking"] | guided by its own engine | watch line: false | routes to Home
Find a place in a project of yours that talks to more than one backend of the same kind (two payment processors, two storage services, two LLM vendors). Search the code for every branch on the backend's name or type. For each branch, write the question it is really asking in one line without naming a vendor, then sketch a small traits struct that answers all of those questions. Count how many branches would disappear if each backend declared its traits.
Hint
Grep for string literals of the vendor names and for is SomeClient / as? SomeClient casts. Most branches collapse into three or four yes/no questions: does it support X, may its output be reused elsewhere, who owns the follow-up work, where does it operate.
Progress
Progress is local-only — sign in to sync across devices.