Architect
Stacks
ReferenceNormative

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

SituationChoice
iPhone and iPadSwiftUI, Swift 6 with strict concurrency
AndroidJetpack Compose, Kotlin, Hilt
BothTwo native apps, one shape, built in the same piece of work
EverNo 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.

iOSAndroid
EventsViewEventsScreen + EventsViewModel
EventsRepository, EventsRemoteSourcethe same names
FeatureRouter, AppShellTabsthe same names
AppSpacing.md, AppTypography.smSemiboldthe 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.

examples/mobile/android/tooling/check_mirror.py
#!/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())
examples/mobile/android/platform-only.json
{
  "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

per platform
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 Core or the design system.
  • Core holds 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.

TierHolds
0Locale, theme, the startup document
1Token store, identity session, API client — the network rule is installed before any request exists
2Remote sources, each taking the API client
3Repositories, 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

  1. Release reset (a configured version can sign everyone out or wipe local data).
  2. The network rule and the remembered must-update refusal.
  3. Stores built; the bundled startup copy decoded off the main thread.
  4. Splash, waiting at most 0.5 s for a fresh startup document.
  5. Welcome (first install only) → sign-in offer → the app shell.
  6. 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.

  • 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 FeatureRouter mapping an entity reference to its screen through an exhaustive FeatureKind, 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.
examples/mobile/ios/App/Core/Navigation/FeatureRouter.swift
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.
examples/mobile/ios/App/Core/Network/RequestPolicy.swift
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)
    }
}
examples/mobile/ios/App/Core/Network/APIClient.swift
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 401 gets 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; honour Retry-After. Reads give up after 15 s in all, actions after 30 s.
  • A mutation is sent once unless it carries an Idempotency-Key or 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.

examples/mobile/ios/App/Core/Outbox/ActionOutbox.swift
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:

MomentCheck
Compile (iOS)Unavailable type aliases make a raw Button, ScrollView, NavigationStack… fail to compile, naming the replacement
Every agent editAn edit hook runs the design-system guard and hands violations back in the same turn
Every commitThe git hook runs the guards on staged files
Every build (Android)The guard runs in preBuild
CIThe 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 build compiles for the device SDK, unsigned. make run signs, 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.

ChangeProof
A rule, a mapping, a reducerA unit test with literal inputs
A componentAn isolated render with fake data — Paparazzi on Android, a compiled harness on iOS
A screen offlineEvery screen driven through a stub transport with no network
ParityThe screen signature for the touched screens
How it looks on a phonemake run on a device, only when asked
examples/mobile/Makefile
# 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.

On this page