Chapter 1 · Gate 01
Getting Started
Airframe is an opinionated layered architecture for native Swift apps — AppKit and SwiftUI working together under one rule for where state lives and who is allowed to change it. This chapter lays out the four layers, the two paths data takes through them, and the single synchronization model that makes the whole thing hold together.
Architecture Philosophy
This guide builds on MVC as the Cocoa platform has always defined it — and that is not “Model, View, ViewController.” In Cocoa’s reading, the three names describe layers, not three kinds of class. The Model layer holds all application state and every method that manipulates it; the View layer displays that state and delegates every user action onward without interpreting it; the Controller layer sits between them, translating user input, system events, and model changes into each other. Each layer is a group of classes — a single “fat model” or a “massive view controller” is not a requirement of the pattern but a failure to split responsibilities within or across the layers — and a single object can legitimately span two layers. What the pattern demands is not a class-naming scheme but a discipline about where state lives and who may change it.
NoteManager out of Note — or a state controller out of a view controller — is a split within a layer, not a new layer.The view controller misreading
The most persistent misconception is that the view controller is the Controller layer. It isn’t — a view controller belongs almost entirely to the View layer. It is a special kind of root view: it composes child views, manages their lifecycle, participates in the responder chain, and responds to events its subviews shouldn’t know about. That is View-layer work. But because the view controller is the first object to receive a user’s action, it is also where application logic quietly accumulates — a view controller that deletes notes, talks to the sync service, and validates input has silently absorbed two other layers. The actual Controller layer is made of objects that are not bound to any single view’s lifetime: Action Controllers that turn a gesture into a validated operation, and View State Controllers that shape model data for one particular presentation. SwiftUI makes the distinction impossible to ignore — there is no view controller at all, and the coordination work it used to hide has to live somewhere deliberate.
The per-screen detour: MVVM, MVP, VIPER
That misreading, not MVC itself, is what the industry has spent two decades reacting to — swapping the massive view controller for a new per-screen coordinator under a new acronym: a view model in MVVM, a presenter in MVP, a module in VIPER. But the question that caused the pain — where does application state live, and who may change it? — is asked once, for the whole app; a pattern whose unit is the screen cannot answer it, only relocate it.
It is no accident that these patterns grew up on iOS, where showing one screen at a time makes the screen look like the unit of architecture. A Mac window dispels that at a glance: sidebar, note list, editor, window title, and menu bar are five presentations of the same state, all obliged to agree the instant a note is deleted — and two view models each holding their own copy of the notes have no source of truth between them. Keeping many separate views in sync is the resting state of a macOS app (and behind its single screen, an iOS app has the same app-level state); MVC read as layers answers it with one place state lives and controllers translating between it and every view. What survives of the per-screen patterns is their valid kernel: a View State Controller is a view model demoted from owner to lens — it shapes model data for one presentation, but owns no state and performs no mutations.
Application state vs. view state
The dividing line between the layers is the difference between application state and view state. Which notebooks exist, which notes they contain, whether a sync is in progress — that is application state: it lives in the Model layer, is updated only through defined write paths, and broadcasts a notification whenever it changes. Which note is selected, whether the sidebar is collapsed, how a table is sorted — that is view state: it describes one presentation of the data and belongs with that view’s controller, following the rule that controllers hold only state that concerns themselves. The distinction earns its keep the moment two views show the same data. A note can be deleted from the note list and from its detail pane. If the deletion logic lives in the detail view controller, the list cannot reuse it — and the view controller may be deallocated the moment its view disappears, mid-operation. Instead, both views hand the gesture to the same Action; NoteManager updates the model; the model posts a change notification; and the list, the detail pane, and the window title all revalidate themselves identically. The view that initiated the change has no special status — it finds out the same way everyone else does.
Unidirectional data flow
The loop in that figure has a name: unidirectional data flow. A gesture becomes an Action; the Action writes to the Model; the Model broadcasts the change; every observer — including the view that initiated it — refreshes from the source of truth. Mutations flow down one path, change events flow back up another, and nothing ever writes backward through the read path. If you arrive from SwiftUI, TCA, Elm, or Redux, you already know this shape — but it is not an import from those worlds. The observer loop at the heart of every unidirectional architecture is original MVC’s model-notifies-views mechanism. Airframe’s philosophy in one sentence: MVC’s layers, with unidirectional data flow as the enforced mechanics.
One thing this guide deliberately does not follow is classic Cocoa’s own implementation of the philosophy: Cocoa Bindings, whose mediating controllers (NSArrayController and its siblings) carried reads and writes through one bidirectional, KVO-synchronized pipe. That was marvelously little code for the data-focused table apps it was designed around, but it offers no seam for validation, no reusable operation, and no answer for a background writer. Airframe keeps the loop and replaces the mechanics: separate read and mutate paths, a validated Action instead of a property write-back, one explicit change notification, and the main actor as the lock.
Why this scales
Because all application state sits in one layer, every controller reuses the same logic, and adding a new view — a search results list, a menu item, a status badge — means adding one more observer, not touching anything that already works. Because the logic itself is extracted into Actions and the Model layer, it is plain Foundation code, testable without a running app; views and view controllers merely render state that is already verified, which is why they need almost no tests of their own. And because every change, whether triggered by a user or a background task, flows through the same write path and announces itself the same way, there is exactly one synchronization story to get right. None of this is dogma imported from elsewhere: the observer loop around a single source of truth is the platform’s oldest pattern — the same loop bindings once automated for the table-shaped apps of their era, made explicit, validated, and enforceable here.
The four layers
An Airframe app is built from four layers, stacked so that dependencies only ever point downward:
- Presentation — views and view controllers. What the user sees and touches.
- Controllers — the coordination layer. A View State Controller shapes model data for display; an Action Controller turns a user gesture into a configured Action.
- Actions — the operations that mutate state, plus the Validators that check whether they’re allowed to run.
- Model — the domain state itself, sitting on top of the packages and libraries that talk to disk, network, and other processes. The source of truth.
Every one of those layers can be described in a sentence, and none of them do the others’ job. A view never reaches past its controller into the model. A model never knows a view exists.
Two paths, one boundary
Data only moves through the stack two ways: a read path, from Presentation down through a View State Controller to the Model, and a mutate path, from Presentation through an Action Controller into an Action and Validator, down to the Model. Both paths converge on the same layer and the Model closes the loop by notifying Presentation when something changes — nothing above it has to ask.
Everything below the boundary line is Foundation-only: no AppKit, no UI framework, no app instance required to run its tests. Only Action Controllers and Presentation are allowed to import AppKit. If a type below the line needs something an AppKit type has, that’s a sign the type belongs above the line — not a reason to import AppKit below it.
@MainActor is the lock
Every layer above the bottom Packages layer is @MainActor-isolated. State lives on the main thread; background work runs on the cooperative thread pool and returns its result via await. There is no mutex, no concurrent data structure, no queue guarding application state — the main actor is the synchronization.
This is a deliberate trade against custom actors for state. An actor would make every read from UI code asynchronous, for a problem @MainActor already solves without paying that tax. The one hazard @MainActor doesn’t remove on its own is re-entrancy — what happens when the same method is called again while an earlier call is still suspended at an await. That’s a concern for Chapter 7; for now, the rule is simply: state reads and writes happen on the main actor, everywhere, without exception.
Walking the loop once
Take a note-taking app as the running example for this guide. A user renames a notebook in the sidebar:
- The sidebar view controller reads its rows through a
NotebookListStateController— the read path. - The user commits an inline rename. An
Action Controllervalidates the new name isn’t empty, builds aRenameNotebookAction, and dispatches it — the mutate path begins. - The Action calls into the Model’s write funnel, which updates the notebook’s title and persists it.
- The Model broadcasts a change notification. It does not know or care that the sidebar exists.
- The sidebar’s state controller — and any other view observing notebooks, including one that doesn’t exist yet — refreshes in response.
Nothing in that list required the sidebar to know about a detail view, or the detail view to know about the sidebar. That decoupling is the entire point of routing every mutation through one layer: views can be added, removed, or rebuilt in SwiftUI without the Model layer changing at all.