Native mobile
Two native apps, one shape — iOS leads and Android mirrors it name for name, one door to the network, cached content first, kept actions, guards that refuse a hand-rolled screen, and proof on a real phone.
Verified 2026-10-03 · Against a production white-label super-app: one SwiftUI app and one Jetpack Compose app built from one shared source tree into a separately branded app per tenant (about 1,100 source files per platform, 2,300 commits). Excerpts are adapted from that codebase, with names changed. Blocks titled
examples/mobile/…are the handbook's own example — one feature built twice, iOS leading, Android mirroring — which its gate builds and tests. The platform pages are SwiftUI and Jetpack Compose.
The choice
| Situation | Choice |
|---|---|
| iPhone and iPad | SwiftUI, Swift 6 with strict concurrency |
| Android | Jetpack Compose, Kotlin, Hilt |
| Both | Two native apps, one shape, built in the same piece of work |
| Ever | No cross-platform runtime (React Native, Expo, Flutter) without a decision record |
Two native apps cost more code than one cross-platform app. They are still the default: each app gets the platform's own controls, accessibility, background rules and store review path, and nothing sits between the app and an OS update. The cost is held down by the rules below, which make the second app a copy of a known shape rather than a second design.
iOS leads, Android mirrors
iOS is the reference for structure, names and design tokens. Android mirrors it
file for file. Same type names, same component names, same token names: if iOS
says AppTypography.smSemibold, Android says AppTypography.smSemibold.
| iOS | Android |
|---|---|
EventsView | EventsScreen + EventsViewModel |
EventsRepository, EventsRemoteSource | the same names |
FeatureRouter, AppShellTabs | the same names |
AppSpacing.md, AppTypography.smSemibold | the same names |
- Mirror the structure, never the bugs. A hard-coded string or a gap found on iOS is fixed on iOS, not copied.
- Every Android file names its iOS original in its header comment, and the iOS file names Android back where both exist.
- A difference that exists only because the toolkits differ (Sign in with Apple, a platform sheet) is listed with its reason in the parity tool's platform-only table, never hidden by shaping app code.
One change, both apps
Every change — feature, fix, copy, design, contract, test — ships on iOS and Android in the same piece of work. A change on one platform is unfinished, not a follow-up.
Done is checked by a tool, not by reading. The production app's screen signature script hashes, for every registered screen on each platform, its words, data, actions, states, blocks and gates, and compares the two. The handbook's smaller mirror rule compares names: every screen, design-system component, data role and token on one side has its counterpart on the other, and a difference the toolkits force is listed with its reason.
#!/usr/bin/env python3
"""Android mirrors iOS name for name: every screen, design-system component, data role and token on one
side has its counterpart on the other.
python3 tooling/check_mirror.py [--root DIR]
Under `--root`, Swift files are the iOS app and Kotlin files the Android app (tests and build output
are not read). It compares top-level, non-private declarations:
- a SwiftUI `XView` in a `Features` folder and a Compose `XScreen` in a `features` folder;
- every type in `DesignSystem/Components` and every declaration in `designsystem/components`;
- every `…Repository`, `…RemoteSource` and `…LocalStore`;
- the members of `AppSpacing`, `AppTypography` and `AppColors`.
A difference that exists only because the toolkits differ is listed in a `platform-only.json` file
anywhere under the root, `{"Name": "why it exists on one platform only"}`; a name listed without a
reason is refused. A project with only one platform has nothing to mirror and passes.
"""
import argparse
import json
import re
import sys
from pathlib import Path
SKIP = {"build", ".build", ".gradle", ".git", ".swiftpm", "DerivedData", "Pods", "node_modules", "tooling", "test",
"androidTest", "testing", "Tests"}
DECLARATION = re.compile(
r"^(?:@\w+(?:\([^)]*\))?\s+)*(?:(?:public|internal|final|open|data|enum|sealed|abstract|inline|value|"
r"indirect|nonisolated)\s+)*(struct|class|enum|protocol|actor|interface|object|fun)\s+(\w+)\b(?!\.)")
PRIVATE = re.compile(r"^(?:@\w+(?:\([^)]*\))?\s+)*(private|fileprivate)\b")
ROLES = ("Repository", "RemoteSource", "LocalStore")
TOKENS = ("AppSpacing", "AppTypography", "AppColors")
def sources(root, suffix):
for path in sorted(root.rglob(f"*{suffix}")):
parts = path.relative_to(root).parts[:-1]
if not any(p in SKIP or p.endswith("Tests") for p in parts) and path.name != "Package.swift":
yield path
def declarations(path):
"""Top-level, non-private names declared in a file (an indented declaration is nested)."""
names = set()
for line in path.read_text(encoding="utf-8").splitlines():
if PRIVATE.match(line):
continue
match = DECLARATION.match(line)
if match:
names.add(match.group(2))
return names
def members(path):
text = path.read_text(encoding="utf-8")
return set(re.findall(r"^\s+(?:static\s+(?:let|var)|(?:const\s+)?val)\s+(\w+)", text, re.MULTILINE))
def side(root, suffix, features, components):
out = {"screens": set(), "components": set(), "roles": set(), "tokens": {}}
for path in sources(root, suffix):
parts = path.relative_to(root).parts[:-1]
names = declarations(path)
if features in parts:
out["screens"] |= {n for n in names if n.endswith("View" if suffix == ".swift" else "Screen")}
if f"/{components}/" in f"/{'/'.join(parts)}/":
out["components"] |= names
out["roles"] |= {n for n in names if n.endswith(ROLES)}
if path.stem in TOKENS:
out["tokens"].setdefault(path.stem, set()).update(members(path))
return out
def platform_only(root):
allowed, problems = {}, []
for path in sources(root, "platform-only.json"):
for name, reason in json.loads(path.read_text(encoding="utf-8")).items():
if not str(reason).strip():
problems.append(f"{path.relative_to(root)}: {name} is listed without a reason")
allowed[name] = reason
return allowed, problems
def violations(root):
root = Path(root).resolve()
ios = side(root, ".swift", "Features", "DesignSystem/Components")
android = side(root, ".kt", "features", "designsystem/components")
if not any(ios.values()) or not any(android.values()):
return []
allowed, out = platform_only(root)
def compare(what, mine, theirs, other):
for name in sorted(mine - theirs - set(allowed)):
out.append(f"{what}: {name} has no {other} counterpart")
screens_ios = {n[: -len("View")] for n in ios["screens"]}
screens_android = {n[: -len("Screen")] for n in android["screens"]}
compare("screen", {f"{n}View" for n in screens_ios - screens_android}, set(), "Android (…Screen)")
compare("screen", {f"{n}Screen" for n in screens_android - screens_ios}, set(), "iOS (…View)")
compare("component", ios["components"], android["components"], "Android")
compare("component", android["components"], ios["components"], "iOS")
compare("role", ios["roles"], android["roles"], "Android")
compare("role", android["roles"], ios["roles"], "iOS")
for token in TOKENS:
mine, theirs = ios["tokens"].get(token, set()), android["tokens"].get(token, set())
compare(f"token {token}", {f"{token}.{m}" for m in mine - theirs}, set(), "Android")
compare(f"token {token}", {f"{token}.{m}" for m in theirs - mine}, set(), "iOS")
return out
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--root", default=".", help="the folder holding both apps (default: here)")
args = parser.parse_args(argv)
found = violations(args.root)
for line in found:
print(line)
if found:
print(f"❌ {len(found)} difference(s) between the iOS and Android apps")
return 1
print("✅ Android mirrors iOS")
return 0
if __name__ == "__main__":
sys.exit(main())
{
"AppNavigationStack": "SwiftUI's stack is a design-system wrapper; Compose navigates with the NavHost the shell owns",
"AppTextRole": "Kotlin cannot nest a type in a composable function; iOS nests it as AppText.Role"
}
Without the signature check, parity decays silently. The production app's audit found the Android side on fixtures and mocks, with 125 files importing Material directly and 118 using literal sizes, before the rule and the tool existed.
Layout
App/
├── MainApp # composition root, built in tiers
├── Navigation/ # root switch, shell, blocking covers
├── Core/ # Auth, Network, Config, Data, Outbox, Telemetry, Push
├── DesignSystem/ # Tokens, Components, Forms, Icons, Guards
└── Features/<Product>/ # one vertical slice per product- Features are vertical slices. Each product owns its models, remote source,
repository and screens. It never reaches into another feature; shared needs
move to
Coreor the design system. Coreholds infrastructure, not rules. Network, identity, storage, the outbox, telemetry, configuration.- The design system knows no feature. See the design system.
Composition and state
One composition root, built in commented tiers, in an order that matters.
| Tier | Holds |
|---|---|
| 0 | Locale, theme, the startup document |
| 1 | Token store, identity session, API client — the network rule is installed before any request exists |
| 2 | Remote sources, each taking the API client |
| 3 | Repositories, each taking local storage and a remote source |
- Only the home's repositories are built at launch. The rest are built on first use and kept for the session.
- Screens read; the root fetches. The root owns stores that fetch (feature flags, configuration). Screens receive a read-only face of them.
- Values that depend on the locale are computed under the root view, not where the scene is declared — set too high, the UI keeps stale strings after a language switch.
- One retry owner. A shared refresh scheduler (failed refreshes back off from 5 s to 5 min) and reload signals (back online, back in the foreground). No feature starts its own timer or retry loop.
The session
There is always a session. Nobody signed in means a guest session, minted by an attested anonymous sign-in. Member versus guest is decided by the account id, never by whether a token exists.
- An expired guest session mints a new guest. An ended member session signs out and tells the person.
- Every private store re-binds when the account changes. Favourites, inbox, wallet and preferences drop the old account's data and read the new one's.
- Device ids and install ids are never credentials.
Start-up, in order
- Release reset (a configured version can sign everyone out or wipe local data).
- The network rule and the remembered must-update refusal.
- Stores built; the bundled startup copy decoded off the main thread.
- Splash, waiting at most 0.5 s for a fresh startup document.
- Welcome (first install only) → sign-in offer → the app shell.
- Blocking covers — must update, maintenance — above all of it, in their own window, over every sheet. They cannot be dismissed.
Every build ships a startup copy fetched at build time, for every channel and language. The build fails without one, so an app never opens to nothing.
Navigation
- One shell: one navigation stack whose root is the tabs. Pushed pages hide the tab bar. No navigation stack nested inside a tab. The selected tab is not remembered; the app always opens on Home.
- Typed routes only. A route enum (iOS) or serialisable route types (Android),
and one
FeatureRoutermapping an entity reference to its screen through an exhaustiveFeatureKind, so a new kind does not compile until it has a screen. - Every way in writes to one pending deep-link store: a notification tap, a universal or app link, a sign-in callback. The shell consumes it on cold start and while running, resets to Home and pushes the destination, so Back lands on Home. A link that needs an account is ignored for a guest.
import Foundation
import Observation
/// Every kind of thing the app can open. A new case does not compile until `FeatureRouter` maps it.
enum FeatureKind: String, Sendable {
case project
}
/// A reference to one thing, from a link, a notification or another feature.
struct EntityRef: Equatable, Sendable {
let kind: FeatureKind
let id: String
}
/// Where the shell navigates. Typed: no string routes.
enum Route: Hashable, Sendable {
case projects
case project(id: String)
}
/// The one map from a reference to its screen.
enum FeatureRouter {
static func route(for ref: EntityRef) -> Route {
switch ref.kind {
case .project: .project(id: ref.id)
}
}
/// `<scheme>://<kind>/<id>`, or a universal link's `/<kind>/<id>` path.
static func ref(from url: URL) -> EntityRef? {
let path = url.pathComponents.filter { $0 != "/" }
let parts = url.scheme == "https" ? path : [url.host()].compactMap { $0 } + path
guard parts.count == 2, let kind = FeatureKind(rawValue: parts[0]) else { return nil }
return EntityRef(kind: kind, id: parts[1])
}
}
/// Every way in writes here: a notification tap, a universal link, a sign-in callback. The shell takes it
/// on cold start and while running, resets to the root and pushes the destination, so Back lands on Home.
@Observable @MainActor
final class PendingDeepLinkStore {
private(set) var pending: Route?
func open(_ url: URL) {
guard let ref = FeatureRouter.ref(from: url) else { return }
pending = FeatureRouter.route(for: ref)
}
/// The shell took the destination.
func take() -> Route? {
defer { pending = nil }
return pending
}
}
- Only the design system presents. Sheets, full-screen covers, alerts and confirmations go through its presenters; a screen never calls the native ones.
- What a tenant offers comes from the server, in the startup document's list of available features — not from build configuration. A screen wraps itself in an experience gate for its feature id. Feature flags are a separate, per-account kill switch.
The network
One door out
Every request leaves through one outbound rule. A send asks the rule to admit the request, then settles the answer with it. The rule:
- names the API version the build was built against (
API-Version) and the build (App-Version: <version>+<build>) only on requests to the platform's own origin — anything else, and any redirect away, gets neither; - keeps requests on the phone while the build is refused (see below);
- is the only way to build an HTTP client. A guard refuses any other.
import Foundation
import Synchronization
/// The one outbound rule. Every send asks it to admit the request, then settles the answer with it:
///
/// - a request to the platform's origin names the API version and the build; any other request, and
/// any redirect away, carries neither, whoever set them;
/// - after a must-update answer, requests to the platform stay on the phone, except the startup reads
/// and one probe at a time, until an answer names its version again.
///
/// `scripts`' guard refuses `URLSession` anywhere but `Core/Network`, so no request goes around it.
final class RequestPolicy: Sendable {
static let versionHeader = "API-Version"
static let buildHeader = "App-Version"
/// What a send carries from `admitted` to `settle`.
struct Admission: Sendable {
let request: URLRequest
/// This request is the one probe let through while the build is refused.
let isProbe: Bool
}
private struct State {
var refused: Bool
var probing = false
}
private let config: AppConfig
private let memory: UpdateRefusalMemory
private let startupPaths: Set<String>
private let state: Mutex<State>
private let onChange: @Sendable (Bool) -> Void
/// `onChange` hears every change of standing, so the must-update cover opens and closes while the
/// app runs, not only at launch.
init(config: AppConfig, memory: UpdateRefusalMemory, startupPaths: Set<String> = ["/api/startup"],
onChange: @escaping @Sendable (Bool) -> Void = { _ in }) {
self.config = config
self.memory = memory
self.startupPaths = startupPaths
self.onChange = onChange
state = Mutex(State(refused: memory.isRefused(build: config.appVersion)))
}
var isRefused: Bool { state.withLock { $0.refused } }
/// Whether `url` is on the platform's origin: same scheme, host and port.
func isPlatform(_ url: URL?) -> Bool {
guard let url else { return false }
return url.scheme == config.apiBaseURL.scheme && url.host() == config.apiBaseURL.host()
&& url.port == config.apiBaseURL.port
}
/// `request` as it may leave. Also applied to a redirect's new request, so a hop away from the
/// platform carries neither header.
func prepared(_ request: URLRequest) -> URLRequest {
var request = request
request.setValue(nil, forHTTPHeaderField: Self.versionHeader)
request.setValue(nil, forHTTPHeaderField: Self.buildHeader)
guard isPlatform(request.url) else { return request }
request.setValue(config.apiVersion, forHTTPHeaderField: Self.versionHeader)
request.setValue(config.appVersion, forHTTPHeaderField: Self.buildHeader)
return request
}
/// `request` as it may leave, or nil when it must stay on the phone. A caller answers nil as the
/// must-update error without sending anything.
func admitted(_ request: URLRequest) -> Admission? {
let request = prepared(request)
guard isPlatform(request.url), !startupPaths.contains(request.url?.path() ?? "") else {
return Admission(request: request, isProbe: false)
}
return state.withLock { state in
guard state.refused else { return Admission(request: request, isProbe: false) }
guard !state.probing else { return nil }
state.probing = true
return Admission(request: request, isProbe: true)
}
}
/// Tells the rule what the answer to an admitted request said, or that none came (`response` nil).
/// Only an answer from the platform counts: must-update refuses this build, any other answer naming
/// its version lifts the refusal.
func settle(_ admission: Admission, response: HTTPURLResponse?, body: Data) {
let verdict = response.flatMap { response -> Bool? in
guard isPlatform(response.url) else { return nil }
if AppError.of(status: response.statusCode, body: body) == .updateRequired { return true }
return response.value(forHTTPHeaderField: Self.versionHeader) != nil ? false : nil
}
let changed = state.withLock { state in
if admission.isProbe { state.probing = false }
guard let refused = verdict, refused != state.refused else { return false }
state.refused = refused
return true
}
guard changed, let refused = verdict else { return }
memory.remember(refused: refused, build: config.appVersion)
onChange(refused)
}
}
/// Where a must-update refusal is kept, so the cover opens at every launch of a refused build.
protocol UpdateRefusalMemory: Sendable {
func isRefused(build: String) -> Bool
func remember(refused: Bool, build: String)
}
struct DefaultsUpdateRefusalMemory: UpdateRefusalMemory {
let key = "network.refusedBuild"
func isRefused(build: String) -> Bool { UserDefaults.standard.string(forKey: key) == build }
func remember(refused: Bool, build: String) {
UserDefaults.standard.set(refused ? build : nil, forKey: key)
}
}
import Foundation
/// Answers a send with the bearer token to carry, and renews it once when the server refused it.
protocol CredentialProviding: Sendable {
func bearer() async throws -> String
func renew() async throws
}
/// The one way a request reaches the platform: admitted by the request rule, carrying credentials
/// unless it is a public read, retried by one owner, and turned into an `AppError` on failure.
final class APIClient: Sendable {
/// How many times a repeatable request is tried in all.
static let attempts = 3
private let policy: RequestPolicy
private let transport: HTTPTransport
private let credentials: CredentialProviding
private let pause: @Sendable (Int) async -> Void
init(policy: RequestPolicy, transport: HTTPTransport, credentials: CredentialProviding,
pause: @escaping @Sendable (Int) async -> Void = { attempt in
try? await Task.sleep(for: .milliseconds(250 << attempt))
}) {
self.policy = policy
self.transport = transport
self.credentials = credentials
self.pause = pause
}
/// The body of a successful answer.
///
/// - `publicRead`: no credentials and no cookies, so a cache in front may share the answer.
/// - A `401` renews the credentials once and sends again; a second `401` is `unauthorized`.
/// - Only a read, or a request carrying an `Idempotency-Key`, is repeated after a retryable failure.
func send(_ request: URLRequest, publicRead: Bool = false) async throws -> Data {
let repeatable = request.httpMethod ?? "GET" == "GET" || request.value(forHTTPHeaderField: "Idempotency-Key") != nil
var renewed = false
var attempt = 0
while true {
do {
return try await sendOnce(request, publicRead: publicRead)
} catch AppError.unauthorized where !publicRead && !renewed {
renewed = true
try await credentials.renew()
} catch let error as AppError where error.isRetryable && repeatable && attempt + 1 < Self.attempts {
await pause(attempt)
attempt += 1
}
}
}
private func sendOnce(_ request: URLRequest, publicRead: Bool) async throws -> Data {
var request = request
if !publicRead {
request.setValue("Bearer \(try await credentials.bearer())", forHTTPHeaderField: "Authorization")
}
guard let admission = policy.admitted(request) else { throw AppError.updateRequired }
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await transport.send(admission.request)
} catch {
policy.settle(admission, response: nil, body: Data())
throw (error as? AppError) ?? .offline
}
policy.settle(admission, response: response, body: data)
if let error = AppError.of(status: response.statusCode, body: data) { throw error }
return data
}
}
Must update
Only one answer means the build must update: 426 with the code
APP_UPDATE_REQUIRED, to a request that named its version. It is never retried. It
is remembered for that build, so the must-update cover opens at every launch until
the person updates. While refused, requests stay on the phone except the startup
reads and one probe at a time; any later answer naming its version lifts it.
Kept actions pause; they are not dropped.
Credentials
- Every request carries
Authorization: Bearer <short-lived product token>, exchanged from the session, and an app attestation header (App Attest on iOS, Play Integrity on Android, through Firebase App Check). - A
401gets one refresh and one retry, never more. - A public read carries no credentials and no cookies, so a shared cache in front may serve it to everyone.
- Tokens live in the platform's secure storage, device-only: the Keychain
(
AfterFirstUnlockThisDeviceOnly) and Android's Keystore.
Retries and errors
- One retry owner per request. Retry only
408,429,5xx, timeouts and dropped connections; honourRetry-After. Reads give up after 15 s in all, actions after 30 s. - A mutation is sent once unless it carries an
Idempotency-Keyor is declared safe to repeat. - Errors collapse to what the person can do: no connection, service unavailable, needs sign-in. Never tell someone to check their connection about an answer the server gave.
Generated clients, pinned together
Each product's API is called through a client generated from its OpenAPI
contract — Swift OpenAPI Generator on iOS, a generated Retrofit SDK on Android.
There is no mobile-only API: the app calls the same /api/<product>/* contract
as every other client.
- Both apps pin the same platform revision, and move it in one commit ("Pin both apps' generated clients to platform X").
- A migrated product is never called by hand again. A guard refuses hand-written requests and reply shapes for it.
- Replies are mapped onto the app's own models, so the stored shape and the network shape move independently.
Data on the phone
Cached content first
A screen draws what the phone holds, then refreshes in place. A failed refresh keeps the content and says so once; it never trades content for an error or a blank.
The phase is decided in one place, the same on both platforms; the code is on SwiftUI.
- A first load draws a skeleton, never a spinner.
- One notice per screen, from one failure boundary, not one per section.
Conditional reads
Every GET carries If-None-Match from the last answer the app kept, and a
304 is handed back as that answer. The app keeps answers itself because the
platform forbids HTTP caches to: answers are filed per tenant, API version, reader
and path, reads that carry a location are never kept, and signing out forgets
them all.
Kept actions
An action the person takes is saved before it is sent, with its idempotency key, and shown as done at once. The outbox sends it again when added, at launch, back in the foreground and back online, backing off to 5 min. It leaves the queue only when the server took it or refused it for good, and a refusal is shown once.
When not to: a payment or anything whose outcome the person must see before moving on is sent while they wait, never kept.
import Foundation
import Observation
/// An action the person took, kept on the phone until the platform takes it. Its id is the
/// idempotency key the platform deduplicates on, so sending it twice is harmless.
struct KeptAction: Codable, Equatable, Sendable {
let id: String
let kind: String
let subjectID: String
}
/// Sends one kept action, carrying its id as `Idempotency-Key`. Throws when the platform did not take it.
@MainActor
protocol KeptActionSending: AnyObject {
func send(_ action: KeptAction) async throws
}
/// Where waiting actions are saved, so they survive a relaunch.
protocol KeptActionStoring {
func load() -> [KeptAction]
func save(_ actions: [KeptAction])
}
/// Actions are saved before they are sent and shown as done at once. The outbox sends what waits when an
/// action is added, at launch, back in the foreground and back online. An action leaves only when the
/// platform took it or refused it for good; a refusal is shown once. A must-update answer pauses the
/// outbox: nothing is dropped.
@Observable @MainActor
final class ActionOutbox {
private(set) var waiting: [KeptAction]
/// The last action the platform refused for good, for the root to show once.
private(set) var refused: KeptAction?
private(set) var isPaused = false
@ObservationIgnored private let store: KeptActionStoring
@ObservationIgnored private let sender: KeptActionSending
init(store: KeptActionStoring, sender: KeptActionSending) {
self.store = store
self.sender = sender
waiting = store.load()
}
func add(_ action: KeptAction) async {
waiting.append(action)
store.save(waiting)
await flush()
}
/// Sends every waiting action once, in order; stops at the first that may succeed later.
func flush() async {
isPaused = false
while let action = waiting.first {
do {
try await sender.send(action)
} catch AppError.updateRequired {
isPaused = true
return
} catch let error as AppError where !error.isRetryable && error != .unauthorized {
refused = action
} catch {
return
}
waiting.removeFirst()
store.save(waiting)
}
}
func noticeShown() { refused = nil }
}
struct DefaultsKeptActionStore: KeptActionStoring {
let key: String
func load() -> [KeptAction] {
UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode([KeptAction].self, from: $0) } ?? []
}
func save(_ actions: [KeptAction]) {
UserDefaults.standard.set(try? JSONEncoder().encode(actions), forKey: key)
}
}
The design system
Screens only assemble shared components. A missing component is added to the design system under its iOS name, never styled inside a screen. The rule is the design system; on mobile it is enforced mechanically:
| Moment | Check |
|---|---|
| Compile (iOS) | Unavailable type aliases make a raw Button, ScrollView, NavigationStack… fail to compile, naming the replacement |
| Every agent edit | An edit hook runs the design-system guard and hands violations back in the same turn |
| Every commit | The git hook runs the guards on staged files |
| Every build (Android) | The guard runs in preBuild |
| CI | The guards again |
The example's guards: SwiftUI rules, Compose rules, and the mirror rule above. Each has tests that plant a violation and expect a refusal.
The guards have no legacy allowlist. What they refuse outside the design
system: raw text, images and controls; styling modifiers; colours, shapes and
gradients; literal sizes (.dp, .sp, numeric padding); native sheets, alerts
and navigation; any image loader other than the shared one.
- Tokens, same names on both platforms: spacing
xs…xxl= 4/8/16/24/32/48, page inset 16, control heights 40/48/56, touch area at least 44 pt / 48 dp, no text smaller than 12 pt. - Page grammar: a product home is page layout → banner → categories → promoted rail → sections of cards. A detail page is a detail layout with its sections. One section-title component draws every heading.
- Forms are typed definitions: one validator; the form checks before save, locks while saving, keeps the draft when saving fails, restores on cancel.
- Accessibility: Dynamic Type and font scale are never capped; line limits lift at accessibility sizes; every label is translated.
Words and pictures
- Every user-facing and accessibility string is translated. A lint refuses a
literal; an exemption carries its reason inline (
// i18n-exempt: …). - Android's text equals iOS's for the same key, checked by a script.
- The startup document can overlay translations, so wording is fixed without a release.
- Pictures have byte budgets: icon 20 KB, illustration 80 KB, full-width illustration 150 KB, photo 200 KB. People pay for every byte on slow, costly mobile data; the budget is not raised.
- One remote image loader per platform (Nuke, Coil), one disk cache, sizes rounded to shared buckets so the CDN serves few variants.
- Videos are links, never downloaded or played in the app.
Telemetry
Features see one telemetry port. One client validates each event against a catalogue and checks consent (off until given). Each destination has its own bounded queue, so a stalled destination drops its own events and never blocks the others. Events name fixed screen names and outcomes, never identifiers.
White-label: one source, many apps
When the same app ships under several brands:
- All behaviour lives in shared code. A tenant folder holds only its identity, brand, configuration, translation overlays and demo data. No feature code.
- Two files per tenant: native identity (bundle and application ids, team, version and build, store keys) and runtime configuration (name, URLs, palette, languages, currency, default pictures).
- The project is generated per tenant from shared inputs; the generated Xcode project and Gradle resources are never edited or committed.
- A guard refuses any tenant name, id, host or picture in shared code. Demo data reaches the app through a generated assembly, empty by default.
- Pictures a tenant may replace are named slots, not file names, and a tenant ships only the pictures a slot or the code draws.
Release
- Build compiles; run installs on a physical device.
make buildcompiles for the device SDK, unsigned.make runsigns, installs and launches on a connected phone; its build is the compile proof. - Never a simulator or emulator. And never claim a visual or device check from a compile or from reading code.
- Build numbers move only on publish.
- Upload and announce are separate steps. Publishing uploads to TestFlight or Play's internal track as a draft. Announcing posts an update manifest; production promotion stays in the store console.
- The update manifest has a fixed schema — current and minimum version and build, store link. Below minimum is a required update, below current an optional one. The schema never changes; a new need gets a new section, because old builds must still read it.
- A maintenance notice can cover every installed app until withdrawn, and outranks update prompts.
- Signing files, store credentials and Firebase configuration are never committed.
Proof
Follow testing: the cheapest proof first.
| Change | Proof |
|---|---|
| A rule, a mapping, a reducer | A unit test with literal inputs |
| A component | An isolated render with fake data — Paparazzi on Android, a compiled harness on iOS |
| A screen offline | Every screen driven through a stub transport with no network |
| Parity | The screen signature for the touched screens |
| How it looks on a phone | make run on a device, only when asked |
# The handbook's mobile example: one feature built twice, iOS leading and Android mirroring it name for
# name, each checked the way the pages say.
.DEFAULT_GOAL := check
.PHONY: check ios android
check: ios android ## both apps, each with its tests and its rules
ios: ## SwiftUI: swift test, then the SwiftUI rules
$(MAKE) -C ios check
android: ## Jetpack Compose: the JVM tests and screenshot tests, then the Compose rules and the mirror rule
$(MAKE) -C android check
Guards are the main defence. A rule that matters gets a script that refuses the violation at edit, commit or build time, not a paragraph.