Adopt the rules-swift pin-and-codify strategy in snapshot mode (third_party/rules-swift @97dc70f + sha256), add codified gates, a conformance audit ledger, and .github/workflows/rules-swift.yml (corpus-pin integrity required, full gate report mode). Remove the docs/rules mirror it supersedes (repo-specific files kept under docs/). Style and leak gates exclude the vendored corpus.
22 KiB
Pre-UI Layer (display-less model)
Status: MANDATORY for any code that has UI. All display state and interaction behavior live in a UI-framework-agnostic, value-typed model. The UI consumes it through a narrow framework-agnostic contract. Renderers translate the model to the target's native presentation and are the only code that imports a UI framework. The result is renderer-agnostic AND platform-portable: one model drives SwiftUI, AppKit, and UIKit on Apple platforms, and because it imports only Foundation the same model builds and runs on Linux, Windows, and WASM, where a different front end (a text or terminal UI, a WASM front end, a platform-native one) renders the same surfaces. The model is blind to which renderer is attached, and it is testable with no display at all.
The premise
The only fundamental split in UI code is what to present (state and behavior, framework-free) versus how to render it (pixels, framework-bound). Everything below is derived from that one cut. This is the Humble Object and Presentation Model pattern: the framework-bound part is made so thin it needs no test, and all the logic sits in a plain object beneath it.
An app does not begin at its UI. It begins at the domain and the engine: what the entities are and what the behavior does. The UI is the last layer attached and the most replaceable one. Start with it and you bake renderer assumptions into decisions that should have stayed framework-free, and you cannot answer "does this behave correctly" until a screen exists. Build the model that runs with no display first, prove it headless, then attach renderers to it. The order is domain and engine first, UI last, which is also why one model can later carry SwiftUI, AppKit, and UIKit at once: nothing about it was ever decided by a renderer.
Below the renderer: two value tiers, three roles
What sits below the renderer is the inner rings of the classic layered architecture, with the renderer standing in for the UI layer. It is not a tall new stack of layers. It is two kinds of value plus the roles that act on them, all framework-free. The reason it reads as "two tiers" in one place and "more" in another is that those are different axes: the tiers are the nouns (what the model is), the roles are the verbs (what reads and writes them). Both are below the renderer; neither is an extra layer of the other.
Two value tiers (pure, Sendable, value-typed, no UI import):
- Domain. What the entities are:
Codablevalue types, reusable beyond any UI. No display state. - Surface. Display state over the domain: selection, expansion, caret position, load state, and the surface organs (the tree, grid, form, document, status, dialog the UI shows). This is the Presentation Model: framework-free presentation state the renderer reflects. The renderer sees surfaces, never raw domain types.
Three roles that operate on those tiers (the behavior, also framework-free):
- Engine (the interactor, the application role): receives every intent and runs the use case. It owns the surfaces and is the one place behavior is orchestrated.
- Data layer (I/O behind a port): all loading and persistence. The engine reaches it only through injected, interface-segregated protocol seams (a repository), never directly.
- Domain services: pure, deterministic computation over the domain (check syntax, validate, transform). The engine calls them and puts the result in a surface.
Style is named by meaning, never appearance (a token, not a color), so the value tiers contain no appearance vocabulary at all.
This is not five peer layers competing for a count. The two value tiers are the nouns; the three roles are the verbs that read and write them; the renderer is the only thing above. In a small app the roles collapse into one module (the engine is a couple of functions, the data layer is one protocol with one conformer, domain services may be inline), but the seams are not optional: one intent channel in, a data-layer port for every I/O, and no UI import below the renderer. Those seams are what keep the model renderable by any front end, testable with no display, and portable off Apple. Collapse the modules; never collapse the seams.
The consumption contract
The UI touches the model through three narrow seams and nothing else:
- Pull (read): interface-segregated capability protocols, composed per consumer, not one wide protocol. A feature injects only the slices it uses, so its fakes are tiny.
- Intent (write): a single
perform(_ intent: Intent), the one inbound door into the engine, whereIntentis a payload-carrying value (a Command:.edit(Edit),.select(SurfaceID),.toggle(SurfaceID),.run(CommandID),.ambient(Ambient)). Every trigger that reaches the engine is one enumerable case, whether a user raises it or the system does, and the model defines and handles all of it: opening or activating the app, a menu-bar or toolbar command, selecting a menu item, a pull-to-refresh, a tap, a swipe, a scroll, a focus change, and every text edit (each keystroke or the committed string), and equally the automatic, no-user events: a timer firing, a notification arriving, a background refresh or a network request completing, an app lifecycle transition, an observed value changing. Those automatic events are.ambient(...)cases on the same channel: the system raises them, but they enter the engine through the one door, never a side path. The caller (renderer or system) reports the intent and re-pulls; it never interprets, decides, or owns behavior. - Notify: a typed change signal, a
Set<SurfaceArea>of what changed. The model emits it; the UI re-pulls the dirty areas. How a renderer observes the signal (Observation, a tracking closure, a re-pull loop) is a renderer detail, not part of the contract.
Completeness: every trigger is represented, in order
The intent vocabulary is exhaustive over everything that can drive the app, from both directions: every action a user can take and every event the system can raise on its own. If a user can perform an action, there is an Intent case for it and the model handles it; nothing is decided in the renderer. A button press is sending a command. A text field is an .edit over a document the model owns (with its own undo and cursor). A pull-to-refresh is a .run of a reload command. A menu-bar or context-menu item is a .run of its command id. Opening or activating the app is an intent the model receives, not lifecycle logic the renderer keeps. And equally for automatic events: if a timer fires, a notification arrives, a background task or network request completes, the app moves to background or foreground, or an observed value changes, there is an .ambient(...) case for it and the engine handles it; that behavior is never left in a timer closure, a notification handler, or a completion block the renderer or the system owns. The caller (a finger or the system) raises the event; the model is the only place it means anything.
This is the same coverage law as ../validation-rules.md, applied to triggers: keep a registry of every trigger the app can receive, the user affordances and the ambient/system events, and a meta-test asserting each one maps to an Intent (or is an explicitly recorded, reasoned gap). Adding a new affordance or a new system event without an intent MUST fail that meta-test. A trigger handled directly outside the engine (an if in the renderer that decides, state the renderer keeps, a network call a button makes, a timer or notification handler that mutates state itself) is the exact violation this rule exists to prevent: it is behavior the headless tests can never reach and the other renderers will not share.
Order is part of the contract. A run of the app is a sequence of these triggers, user and ambient interleaved as they actually occur, and the engine's outcome depends on that order. So the test corpus is not a set of isolated triggers; it is the ordered sequence of intents a real session produces, ambient events recorded in their true positions (the timer fires after the load completes, the notification arrives while the editor is focused). The same ordered corpus is what runs on-device and headless (see flowspec.md): the device UI test and the headless replay drive the identical sequence, so the order that matters in real usage is the order the tests assert. A test corpus that exercises each trigger once but never in a realistic, ordered run, or that omits the ambient events that interleave with user actions, is incomplete by this rule even if every individual intent is covered.
Every intent has a documented response
The half of the model that receives intents and runs behavior is the engine (the interactor): given an intent, it runs the use case. For any loading or persistence it calls the data layer, the repositories and data sources behind injected, interface-segregated protocol seams; the engine orchestrates and owns the surfaces, but it does not do I/O itself, the data layer does. It maps the result into the named surfaces, sets load and failure state, and emits the change signal. So an intent's round trip is the two value tiers acted on by the three roles: the engine (behavior) maps Domain entities, fetched through the data-layer port and refined by domain services, into Surface display state, all framework-free. Every intent's effect is documented as data, what it does, which data it touches, which surface areas it mutates, and its loading and failure states, so each interaction is a recorded round trip: affordance to intent to engine effect. A pull-to-refresh is .run(reload), whose effect is: the engine asks the data layer to load, sets the list's load state to loading then loaded or failed, maps the returned entities into the list surface, and notifies the list area. Typing is .edit, whose effect is: the document applies the edit, the cursor moves, the editor surface is notified. Selecting a menu item is .run of its command id, whose effect is that command's behavior. An arriving notification is .ambient(.notification(payload)), whose effect is documented the same way: the engine routes the payload, maybe asks the data layer to fetch, maps the result into a surface, sets a badge or banner surface flag, and notifies those areas, exactly as a user-raised intent would, with no behavior left in the notification handler. Because the engine reaches data only through the protocol seams it is given, a headless test injects a fake data source and the same intent produces the same surfaces with no network. The coverage meta-test covers BOTH halves: every affordance has an intent, and every intent has a defined, documented effect (its work, its mutated surfaces, its loading/failure states). An intent whose effect is undocumented, or that the engine performs ad hoc without recording what it touches, fails this rule as surely as an unrepresented affordance. The point of writing both halves down is that the round trip is then testable headless and identical under every renderer: the same pull-to-refresh runs the same reload and produces the same surfaces, on SwiftUI, on AppKit, and off-Apple.
The engine is the caller; what it calls lives by kind. Anything with I/O (load, save, a network request) is the data layer, behind an injected seam. Any pure analysis of the domain (check syntax, validate, parse, transform) is a domain service, a deterministic, framework-free function the engine calls and whose result it puts in a surface (the diagnostics, say). Whether an action is enabled is neither a call nor the renderer's question: the engine derives it from current state and bakes it into the surface as a flag the renderer only reflects. So load data is the data layer, check syntax is a domain service, enable actions is engine-computed surface state, and the engine is the one place that orchestrates all three in response to an intent.
The renderer
Renderers are thin adapters: they translate surface values into the target's native presentation (SwiftUI, AppKit, or UIKit views on Apple platforms; a text grid, a WASM tree, or another platform's widgets elsewhere) and are the only code permitted to import a UI framework. There can be several at once, one per renderer technology, all binding the same model. On Apple platforms the standing aspiration, pursued whenever bandwidth allows, is to ship all three native renderers (SwiftUI, AppKit, and UIKit) against the one model: three idioms, one behavior, proven identical by the same headless corpus. That triple render is the architecture's payoff, not an afterthought, and a model that only ever drives one renderer is leaving the guarantee unused. The triple render is also a comparison instrument: with one model and identical behavior underneath, building the same surface in each SDK isolates the renderer as the only variable, so which framework behaves best in a given scenario (animation smoothness, accessibility, gesture handling, layout under load, large-list performance) becomes an apples-to-apples test rather than a guess. Reach for it whenever a scenario's UI behavior is uncertain. Each adapts the change signal its own way: SwiftUI through Observation, AppKit through withObservationTracking, a non-reactive or off-Apple front end through a re-pull loop. The set of renderer packages is named explicitly (the UI boundary). A renderer holds no behavior worth testing; the single place appearance happens is one color(for: token) map (or the target's equivalent).
Make impossible states unrepresentable, then validate the residue
Push every total invariant into the types: a non-empty selection wrapper, a smart constructor that rejects the bad value at init, so the bad state cannot be built. Validate only what the type system cannot carry, with a default validator per public surface type (see ../validation-rules.md: every public type is validated or excluded with a reason). The strongest coverage is the rule you delete by making the bug unwritable.
Enforcement
The no-UI-import invariant earns mechanical enforcement, because it is brutal to retrofit once a UI type leaks inward. Ship a build-failing check (a script in CI) that forbids any UI-framework import outside the named renderer boundary set, and fails naming the offending file. A documented manual audit is not enough.
Proof: testable headless
Because all behavior is in value-typed models behind a framework-free contract, the real models run with no display. The strongest form runs the same ordered scenario corpus the device UI tests use, replayed headless against the models (see flowspec.md); only pixel-geometry steps are skipped. The corpus drives the real ordered sequence of triggers, user actions and ambient events interleaved as a real session produces them (a .run(reload) then, mid-load, an .ambient(.notification(...)), then an .edit), so the order that decides behavior in production is the order the tests assert. Do not keep a separate hand-written headless test set that can drift from the device scenarios, and do not reduce the corpus to each trigger fired once in isolation.
Run the real async; control only the environment
The replay must exercise the model exactly as it runs in production: its real concurrency, never a synchronous reduction of it. The engine's actual async/await, its actor isolation, its Task scheduling, and the real interleaving of concurrent operations all run during the test, the same code paths that ship. Determinism comes from controlling the environment, not from erasing the asynchrony:
- Time is injected, not real. The engine takes its time source as a
Clock(the Swift standard libraryClockprotocol; a test conforms its own clock and advances it by hand). A timer, a delay, or a debounce then fires at a controlled instant, deterministic and immediate in wall-clock terms, while the suspension points themselves stay real. Never sleep against the real wall clock in a test. - Ambient sources are injected behind their seams. Notifications, network responses, and background-task completions arrive through the same interface-segregated seams the engine already reads, fed by the test as an
AsyncStreamor a resumed continuation. The test decides when each completes and in what order, so two operations in flight finish in the order the scenario pins, and a race that matters in production becomes a reproducible step rather than luck. - Outcomes are awaited, not slept for. Assert that the asynchronous effect actually happened, the expected number of times, with Swift Testing's
confirmation, not by sleeping and hoping; the test proceeds when the real work completes.
The result is the real program under deterministic conditions: the actual async behavior and the actual ordering, reproduced exactly, with only the wall clock and the external deliveries under the test's control. A replay that makes the engine synchronous, drops the actor hops, or swaps awaits for immediate returns is testing a different program than the one that ships, and exactly the failures this discipline exists to catch, ordering, re-entrancy, and races, never surface.
DO
- Split the model into Domain (pure data) and Surface (display state over it); both framework-free, value-typed,
Sendable. - Expose reads as interface-segregated capability slices; expose writes as one
perform(_ intent: Intent)with a payload-carryingIntentvalue, and route automatic, no-user events (timer, notification, background or network completion, lifecycle, observed change) through that same door as.ambient(...)cases. - Emit change as a typed
Set<SurfaceArea>; let the renderer choose how to observe it. - Keep renderers thin: surface-to-native translation only, the one place appearance happens, the only UI-importing packages.
- Make total invariants unrepresentable; validate the residue with a default validator per public surface type.
- Gate the no-UI-import rule with a build-failing check naming the renderer boundary.
- Replay one ordered scenario corpus both headless and on-device, with user and ambient triggers interleaved in their real order.
- Run the headless replay through the model's real concurrency (real
async/await, actors,Taskscheduling); get determinism from an injectedClockand injected ambient seams, not from making the engine synchronous; await async effects withconfirmation, never a sleep.
DON'T
- Do not put display state or behavior in the renderer; the renderer reports intents and re-pulls, nothing more.
- Do not import a UI framework below the renderer layer, and do not name a color, font, or size in the model (style names meaning).
- Do not grow an open-ended delegate of many methods; one
Intentchannel for user and ambient triggers alike. - Do not leave behavior in a timer closure, a notification handler, or a completion block; raise an
.ambient(...)intent and let the engine own it. - Do not depend on the model from a renderer and the renderer from the model; the dependency is one way, renderer to model.
- Do not rely on a manual audit to keep UI imports out; gate it in the build.
- Do not keep device scenarios and headless tests as two corpora that drift; share one.
- Do not test triggers only in isolation; the corpus is the real ordered run, ambient events in their true positions.
- Do not make the engine synchronous or stub the async away to test it; run the real concurrency and control the
Clockand the seams instead, so races and ordering bugs actually surface.
Acceptance check
A conforming UI codebase has: (1) a framework-free, value-typed model in two tiers (Domain pure data, Surface display state), grep-clean of any UI-framework import (SwiftUI/UIKit/AppKit, or the equivalent UI layer on another target platform) outside the renderer set; (2) reads as interface-segregated slices and writes as a single perform(_ intent: Intent) over a payload value; (3) a typed Set<SurfaceArea> change signal, with observation left to the renderer; (4) renderers that only translate surface to native and are the sole UI-importing packages, named as a boundary set; (5) a build-failing import-confinement check (not a manual audit); (6) total invariants made unrepresentable, with a default validator per remaining public surface type; (7) the real models tested headless by replaying the same ordered scenario corpus the device tests use, user and ambient triggers interleaved in their real order, exercising the model's real concurrency (real async/await, actors, Task scheduling) with time injected as a Clock and ambient sources injected behind their seams and async outcomes awaited via confirmation, not a synchronous reduction of the program; and (8) every trigger the app can receive, user affordance and ambient/system event, maps to an Intent (a .ambient(...) case for the automatic ones), and every Intent has a documented engine effect (its work, its mutated surfaces, its loading and failure states), both asserted by a coverage meta-test, with no trigger or effect handled outside the engine. A codebase that lets a UI type leak into a feature or model, or that owns behavior in the renderer, or that handles any trigger (a menu command, a refresh, a keystroke, a timer, an arriving notification, a completion callback) outside the model, fails this rule even if it runs.
How it composes (the pattern is not invented)
This is the Humble Object and Presentation Model, realized as a composition of established patterns: Domain and Surface are the two value tiers of the layered model; the engine is the interactor or use-case role (a Facade over the services it orchestrates); the data layer is a Repository behind a port; Intent is Command; the read slices are Interface Segregation; the change signal is Observer; a renderer is an Adapter; and there is deliberately no Singleton (collaborators are injected, see ../dependency-injection.md). It is the abstraction-versus-presentation separation that user-interface architecture theory has described for decades: the renderer is the layered architecture's UI layer, the engine its application layer, the data layer its infrastructure reached only through a port, and dependencies point one way, inward.
Companion rules
pom.mdandflowspec.md: how the UI is tested (page objects driven from a shared scenario corpus, replayed headless against these models).../validation-rules.md: every public surface type is validated or excluded with a reason.../package-import-contract.md: the per-target import contract the renderer-boundary gate enforces.../dependency-injection.mdand../core/no-shortcuts-first-principles.md: no Singleton, constructor injection, make impossible states unrepresentable.