Case study · Transportation

WARP

A two-sided ride-hailing platform for Norway: a rider app for booking a fixed trip, a standard taxi, or an hourly rental with a driver, and a sibling driver app that handles onboarding (phone, email, ID, vehicle) before anyone can accept a ride. Both apps share one architecture I set the direction for, still short of a public launch.

Role
iOS Architect & Lead
Platform
iOS (SwiftUI) — 2 apps: rider + driver
Stack
Swift 5 · SwiftUI · Google Maps/Places SDK · URLSession · Keychain
Scale
27 commits · ~17,158 lines of Swift · pre-launch
Overview

Two clients, one platform, one architecture.

WARP is a rider app and a driver app for a ride-hailing platform built for the Norwegian market: Oslo-centered map defaults, Norway-formatted license plates, a backend hosted in AWS's Stockholm region. The rider app lets someone book a fixed-route trip, hail a standard taxi, or reserve an hourly rental with a driver, then track whoever picks up the request on a live map, message them, and pay in-app. The driver app is the funnel a driver has to clear before any of that can happen: phone and email verification, a captured ID document, an address, and a registered vehicle.

I set up both clients' architecture and technical direction: the shared networking/auth/Keychain layer, the repository pattern each domain sits behind, and the platform decisions (iOS minimum, device support) each app shipped against. I also directed an intern, Sina, who implemented much of the feature work on the rider app under that direction from late 2025 onward, including changes I made directly on his machine.

The problem

A rider needs one app that can request three different kinds of ground transport, track whoever picks up the request on a live map, and pay. The backend offers no push or streaming channel, so every piece of "real-time" state has to be reconstructed by polling. A driver needs an entirely separate funnel proving who they are before the platform lets them near a rider's request at all.

Two different jobs, two different apps, but one system underneath. The real requirement wasn't either feature set on its own; it was a networking, auth, and session foundation solid enough that the second app didn't have to be a rewrite of the first.

Constraints

  • Platform minimums that were still moving. The rider app's iOS 16.6 minimum was set explicitly on 2025-10-31 (a real, git-visible decision, not a default), while the driver app's deployment target disagrees with itself between build configurations, a sign the project's own baseline was still settling.
  • Different device-support policies on each app. The rider app is iPhone-only; the driver app targets iPhone and iPad. The two clients haven't converged on one device-support policy.
  • One hardcoded backend host per app, no environment switching. Neither app has a dev/staging/prod scheme: changing environments means editing source, not flipping a build configuration.
  • App Transport Security disabled app-wide, not scoped. Both apps' Info.plist turns off ATS entirely rather than carving out one exception domain. That's a normal way to move fast against a bare development backend, and a known item to scope down before either app goes further than internal testing.
  • No automated test target in either Xcode project. All correctness during the busiest development weeks came from manual verification against real device builds, not regression tests.
Architecture

A shared core behind two separate apps.

The driver and rider apps are separate Xcode targets against separate backend hosts, not one shared platform binary, but underneath both sits a networking, auth, and Keychain layer that was written once and forked into each.

WARP architecture: a driver-onboarding app and a rider-trip app, each talking to their own plain-REST service on EC2, sharing a networking/auth/Keychain core that was authored once and forked into both. Shared networking core Authored once · forked into both apps DRIVER APP · SWIFTUI Onboarding Screens 11 steps: phone → ID → address → vehicle ViewModels Per-screen form + call state Repositories Auth · Car · Address · Vehicle · Profile · Country BACKEND · EC2 · EU-NORTH-1 Driver Service REST · plain HTTP Rider Service REST · plain HTTP RIDER APP · SWIFTUI HomeViewModel Map, trip type, polling, sheet state — one class GoogleMapsView + Places SDK Map render, geocoding, autocomplete Repositories Trip · Taxi · Rental · Chat · Payment REST REST
The driver and rider apps are separate targets against separate backend hosts, but they share a common origin: the networking/auth/Keychain layer beneath both was written once and forked, which is why file headers in the rider app still read WARPDriver in places.

A driver's onboarding runs entirely inside its own Xcode target: eleven screens, each backed by its own ViewModel and pushed onto one NavigationStack as a VerificationScreen enum case, calling one of seven domain repositories (auth, car, address, vehicle, profile, country, onboarding) that all funnel through the same generic NetworkService. A rider's trip runs through the sibling app's HomeViewModel, a single class that owns map state, trip-type selection, and the polling loops that stand in for a push channel: every 10 seconds it re-fetches trip and driver-location state, and every 5 seconds a separate timer polls trip chat.

Both apps store their access and refresh tokens the same way, Keychain rather than UserDefaults, because that layer, along with the request/response plumbing around it, was written once and forked into both codebases rather than shared through a package boundary.

Engineering decisions

Decisions shaped by building two clients against one backend.

01

A shared networking core, forked twice, not packaged

Three pieces make up the lowest-level networking/auth/Keychain layer: the token manager, the generic network service, and the per-domain repository pattern. All three were authored once and copy-pasted into both the driver and rider apps rather than pulled into a shared internal package. It shows in the file-header timestamps: the rider app's networking files carry dates and structure that predate its own git history, and some of its endpoints still point at driver-only paths left over from the fork.

02

REST polling instead of a realtime channel

Driver location, trip status, and chat all update through timed polling (10 seconds for trip/location, 5 seconds for chat) rather than a WebSocket or push channel. With one small backend host and no socket server, polling was the fastest way to a working v1; the cost showed up later as a real reliability bug in December.

03

Repository-per-domain over a monolithic API client

Both apps wrap a single generic network service behind narrow, per-domain repository structs (auth, address, vehicle, trip, taxi, rental, chat, payment) instead of one large API client class. Each screen's ViewModel only ever sees the handful of calls it actually needs.

04

An enum-driven, single-NavigationStack onboarding router

The driver app's entire eleven-step onboarding funnel, phone OTP through vehicle registration, is modeled as one VerificationScreen enum matched by a single navigation destination handler. The whole step order lives in one file and is easy to reorder, at the cost of no formal state machine enforcing that a step actually completed before the next one is pushed.

05

Three ride products as three separately versioned DTO sets

The rider app's fixed trip, standard taxi, and hourly rental are implemented as three parallel repository/DTO sets rather than one generic "travel" model, because the products genuinely differ server-side: rental carries an hourly-drive-time field the others don't. The cost is triplicated create/confirm/cancel logic across all three paths.

Trade-offs

What it cost, on purpose.

Building a second client fast, on top of the first one's foundation, bought speed at a few specific prices.

Forking the driver app into the rider app

The rider app had a working networking, auth, and map foundation from day one instead of a blank Xcode project. The bulk import that created it landed over 7,700 lines in a single commit.

The cost

Leftover driver-app artifacts shipped into the rider binary: file headers, a reused endpoint path, a fallback bundle-id string. The kind of residue a shared internal package would have caught.

REST polling over a realtime channel

One small backend, no socket server to build or operate, and a debuggable request/response model for trip status, driver location, and chat.

The cost

By December the team was firefighting the exact symptom a push channel avoids: a stale-cache bug briefly let the polling loop exit early instead of refreshing a rider's trip/driver-location view.

One HomeViewModel owns nearly everything

Cross-cutting trip-state changes (resetting destination state on a new pin, for instance) are a one-file edit, which mattered for a two-person team shipping daily fixes.

The cost

That file grew to 1,827 lines and was touched by nearly every bug-fix commit in the December sprint: a shared-mutable-state bottleneck, not a place two people can work independently.

Repository-per-domain without a shared composition root

Each screen constructs exactly the repository it needs, with no dependency-injection framework to learn or configure.

The cost

Neither app's repository aggregator, built to compose everything in one place, is ever actually instantiated. Adding something like token refresh means touching every ViewModel's initializer individually instead of one seam.

Technical challenges

The parts that fought back.

A real-time polling bug, caught and fixed the same day

The trip/driver-location polling loop only re-fetched the current trip when its local cache was empty, then derived the trip's type from that same cache. If the cache went stale, the loop would silently exit early instead of refreshing, leaving a rider's "waiting for driver" or "trip in progress" screen stuck. Fixed by always re-fetching the current trip on every loop iteration and deriving its type live instead of from the stale cache, hours before that day's build went out.

The map camera fighting the person placing a pin

Every marker update was auto-fitting the camera to all markers, which meant the map kept recentering itself while someone was actively dragging a pin to set a pickup or destination. Fixed by making camera-fit opt-out for the flows where the user is mid-selection, plus explicit state resets so a fresh pin pick didn't inherit a stale route.

A payment redirect racing against polling-driven state

The payment gateway hands control back to the app through a custom URL scheme, which has to land cleanly on top of whatever sheet state the trip's polling loop has already put the screen into. Getting that reconciliation right, without a shared session or nonce to validate the redirect against, took more than one pass in the same week.

An eleven-step funnel with no formal state machine

The driver app's onboarding sequence is coordinated entirely by navigation-stack pushes from inside each screen's button handler; nothing centrally enforces that a step actually completed before the next one fires. It works, but validation is whatever each screen happens to check locally. One step, address capture, has a known rough edge around forwarding the exact selection through to the backend, an item being tightened up as the flow matures.

Performance & reliability

Where two apps actually stand today.

17,158
Lines of Swift
187 files across both apps: 135 in the rider app, 52 in the driver app.
27
Commits
25 on the rider app (Apr–Dec 2025), 2 on the driver app (Mar–Jul 2025).
0
Automated tests
No XCTest target in either Xcode project; correctness comes from manual verification and a fast fix-commit loop.
Security

Early-stage, and honest about it.

Neither app has shipped publicly yet, so this is a snapshot of what's solid and what's a known pre-release item, not a finished security posture:

  • Transport — both clients currently point at a single, fixed development backend over plain HTTP, with App Transport Security disabled app-wide rather than scoped to one exception domain. That's a normal way to move fast against a bare development host; it's also a tracked item to close before either app goes further than internal testing.
  • Secrets — the rider app's maps integration currently uses a client-embedded API key. Rotating it and restricting it server-side is a known item ahead of any public release, not something addressed yet.
  • Auth — both apps store access and refresh tokens in the iOS Keychain, not UserDefaults, and clear them automatically on an unauthorized response. Neither app actually exchanges a refresh token for a new access token yet, though — an expired session means a clean logout today, not a silent renewal.
  • Logging — both apps' network layer currently logs full request and response bodies unconditionally rather than behind a debug-only build flag. Useful during active development, and a known item to gate before wider distribution.
  • Identity verification — a driver clears phone OTP, email OTP, and ID-document capture before the backend can approve them, a reasonable baseline for an onboarding funnel at this stage.

Because both apps are still short of a public release, this list doubles as the actual pre-launch hardening backlog, not a disclosure about something already shipped.

Engineering journal

How the two repos' histories line up.

2025-01 · Foundation

One networking core, written once

The Keychain-backed token manager, the generic network service, and the per-domain repository pattern both apps still use today predate either app's own git history: file headers on the lowest-level networking files carry this date, months before the driver app's first commit.

2025-03 · Driver: Bootstrap

Driver app scaffolded

Initial commit: a stock SwiftUI template, 9 files. The bulk of the onboarding flow's functional code (token manager, all seven repositories, the phone/OTP/address/vehicle ViewModels) was actually written in about a week that March, per file timestamps, but wasn't committed until months later.

2025-04 · Rider: Bootstrap

Rider app forked from the driver codebase

An initial commit established the rider app as its own target; a month later, a single commit bulk-imported over 7,700 lines of screens, ViewModels, and networking code, largely copy-pasted from the driver app, file headers and all.

2025-07-17 · Both apps

Onboarding UI lands; Places SDK goes in

The driver app's second and, to date, last commit squashed months of local work into one commit adding the full onboarding UI, fonts, and assets. On the same day, the rider app added the Google Places SDK and reorganized its asset catalog.

2025-10-31 · Rider

Directing Sina's implementation work

After a quiet stretch, a commit setting the rider app's iOS minimum to 16.6 marked the first commit from Sina, an intern I brought onto the rider app and directed on both architecture and specific implementation from here forward. A meaningful share of what shipped after this point was me working directly on his machine, which is part of why the commit history splits unevenly between us.

2025-12-04 to 12-09 · Rider

Deep links, map bugs, a five-day push

A run of fix commits (deep-link/payment-return handling, map-camera behavior, sheet gestures) landed inside a five-day window, alongside build-number increments as the app moved through iterative TestFlight uploads.

2025-12-12 · Rider: Most recent

Last recorded activity: a polling fix, then build 16

A fix for the stale-cache polling bug landed the same day build 16 went out. It's the most recent commit in either repository. Both apps remain at this pre-launch stage as of this writing.

Lessons learned

What I'd tell myself at the start.

  • Reusing a sibling app's codebase accelerates a second app, but without a shared package boundary the implementation details leak: a stale endpoint path, a file header that still names the wrong project. If a third client ever joins this platform, that shared core should become an actual package, not another fork.
  • Directing someone else's implementation is a different skill than writing the code yourself. Process helped less than making the "right" way to add a new call the obvious way in the code itself: one enum for endpoints, one pattern for repositories, so work that happened without me in the room still landed consistently.
  • REST polling is a fine shortcut for real-time state early on, but the December bug-fix cluster is exactly the failure mode a push channel avoids. Worth revisiting before either app takes on real trip volume.
Outcome

Two apps built together, neither one shipped yet.

WARP is a rider app and a driver app built on the same architectural foundation: Keychain-backed auth, a repository pattern over one networking core, and the platform-minimum decisions each app shipped against. The driver app currently covers onboarding only: phone and email verification, ID-document capture, address, and vehicle registration, with no trip, dispatch, or earnings surface yet. The rider app reached build 16 against a 1.0 marketing version, with several build increments and a working fix in its final week, but git activity on both repositories has been quiet since December 2025.

Treat this as an in-progress platform rather than a shipped product. There are no user or ride-volume numbers to report, because there isn't a live audience yet. What's real is the architecture, the December bug-fix cadence that shows a system converging on stability, and the direction I set for a second engineer to build against.