
cmux-architecture
Popularcmux package architecture, refactor layering, dependency inversion, file organization, DocC documentation, package design discipline, testability, and Swift 6 concurrency rules. Use before adding or meaningfully rewriting Swift files, Swift packages, coordinators, services, repositories, or public package APIs.
cmux package architecture, refactor layering, dependency inversion, file organization, DocC documentation, package design discipline, testability, and Swift 6 concurrency rules. Use before adding or meaningfully rewriting Swift files, Swift packages, coordinators, services, repositories, or public package APIs.
cmux Architecture
Package architecture
cmux is migrating from a single app target into Swift Packages under Packages/. Every new package must be:
- Ergonomic. Default to internal access;
publiconly what downstream consumers actually use. - Acyclic. Packages form a strict DAG. Share a type by lifting it to a lower package or defining a protocol seam in the consumer. Every new dependency edge requires re-checking that the graph stays acyclic.
- Whole-domain. One package owns a full domain (settings, appearance, workspace, terminal, browser, command palette).
CmuxAppearanceMath+CmuxAppearanceTheme+CmuxAppearanceSettingsis folder structure insideCmuxAppearance, not module structure. A boundary exists because more than one consumer needs the contents, or a build/test seam must exist.
When in doubt, extract leaf-first: the package with no internal dependencies. Existing packages under Packages/ predate this policy; do not use them as design references.
Wiring a new package into cmux.xcodeproj needs explicit pbxproj entries in both the cmux and cmux-unit targets. See references/package-boundaries.md.
Group folders. Every package lives physically under exactly one group directory: Packages/Shared/<pkg> (both apps), Packages/iOS/<pkg> (iOS only), or Packages/macOS/<pkg> (macOS only). cmux.xcworkspace/contents.xcworkspacedata mirrors that folder shape, with three groups whose container locations are those folders and every package directory as a FileRef under its folder's group. The folder is the source of truth: to move a package, git mv the directory then run python3 scripts/check-workspace-package-groups.py --write. Cross-group .package(path:) deps use ../../<Group>/<Name>. Never hand-edit workspace group membership. CI runs python3 scripts/check-workspace-package-groups.py --check and fails on drift.
Lockfiles. Do not gitignore cmux-owned Package.resolved files; SwiftPM resolution changes must be visible in PR diffs. Track the root Xcode lockfile and every cmux-owned package-local Package.resolved produced by standalone swift package resolve / swift build / swift test. A package-local lockfile is the source of truth for that package's standalone resolution and is not replaced by cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved. Vendored third-party directories may keep their upstream ignore policy. CI runs python3 scripts/check-package-resolved-policy.py.
Feature flags mean remote PostHog runtime flags. Unless the user explicitly asks for a compile-time flag, local setting, or environment variable, implement a feature flag through CmuxFeatureFlags with a PostHog key, an explicit unavailable fallback, registry metadata, live update behavior, and focused tests. A local override may support dogfood but must not be the production control plane.
Layers
Five layers, dependencies point only downward:
- Core (
CmuxCore): pureSendablevalues, IDs, DTOs, errors, shared protocol seams. No AppKit/SwiftUI/I/O. The lift target when two domains need the same type. - Services / infrastructure:
actors implementing core protocols against the outside world (process/PTY, filesystem, sockets, web API, notifications, auth). One package per cohesive capability. - Domain / state:
@MainActor @Observablemodels plus Coordinators, one package per feature domain, owning that domain's mutable state. ExemplarCmuxSettings. - UI: SwiftUI/AppKit views, one UI package per domain package, depending only on its domain package plus Core, never a Service directly. Exemplar
CmuxSettingsUI. - Executable (
cmuxApp/AppDelegate): thin composition shim, no business logic.
Classify every extracted entity by intent:
- Coordinator:
@MainActor @Observableorchestrator that sequences a user flow and owns navigation/selection/lifecycle state, calling Services and child models. Does no I/O itself. - Service:
actor(or@MainActoronly when an AppKit main-thread API forces it) performing one outside-world capability; exposesasync/awaitplusAsyncStream; holds only its own resource handles and no UI state. - Repository:
actormediating one persistence source of truth (file, defaults, web API) behind CRUD-shaped async methods returning value types. Precedents:JSONConfigStore,UserDefaultsSettingsStore.
Dependency inversion. Lower packages publish protocols; concrete Services/Repositories conform; higher layers depend on any Protocol, never the concrete type, and never a stored property reaching across modules. Constructor (init) injection only: no global container, no singleton, no static let shared. The executable app target is the single composition root, the one place concretes are named and the object graph is assembled. SwiftUI Environment may carry already-constructed @Observable models down a view tree (as SettingsRuntime does), never service wiring.
State and SwiftUI. Domain state lives in @MainActor @Observable models, never ObservableObject/@Published. A god model decomposes into cohesive child @Observable sub-models owned by their domain packages and composed by held reference; cross-domain reads go behind read-only protocols. In views use @State (owned), @Bindable or plain let (passed in), or @Environment(M.self) plus .environment(...) (injected). Never @StateObject / @ObservedObject / @EnvironmentObject / .environmentObject(_:).
Executable-target boundary (invert, never work around):
@maincmuxAppandAppDelegatestay in the executable target as the thin composition shim. That residual is the intended end state, not debt.- A type is declared in exactly one module and a lower package cannot extend a higher-owned type, so
AppDelegate+*/cmuxApp+*/Workspace+*extensions do not move down. Extract the behavior into a Coordinator/Service/Repository, have the god object own an instance, and reduce the extension to a one-line forward. - Stored properties cannot cross module boundaries. Decompose god-model state into child
@Observablesub-models owned by domain packages, composed by held reference, with cross-cutting reads behind read-only protocols.
File organization
One major type per file, named after the type (Control.swift, LabeledChoice.swift, ListControl.swift, not one shared SettingControl.swift). Applies to all new code in Packages/ and all new app-target files.
- Trivial private helpers, nested types, and single-line extensions used only inside the file may stay with the parent type. Anything with a meaningful body gets its own file, including a
private final classnested in another file's type. - Conformance-adding extensions for a type defined elsewhere go in
TypeName+Conformance.swiftorTypeName+Feature.swift, not bundled into the consuming feature file. - Type-erased wrappers live next to what they erase:
Foo.swiftandAnyFoo.swift. - The god files (
ContentView.swift,Workspace.swift,TabManager.swift,cmuxApp.swift) are what this rule exists to stop. Splitting one file per type is correct even if it triples the file count. File count is cheap; "find this type" being unanswerable is expensive.
Documentation
Every public symbol in a new package under Packages/ gets a Swift-DocC /// comment at the time it is written. Docs are part of the API surface, not follow-up work.
- First line is a one-sentence summary that fits on one line and ends with a period. Blank
///line before any discussion paragraph. Use- Parameter name:/- Returns:/- Throws:oninitandfuncsymbols that take parameters or throw. Markdown is fine. - Reference symbols with double backticks (
CmuxSetting); plain backticks for non-symbol code (UserDefaults.standard). - Document what a type represents and when to use it, the meaning of each enum case, init parameter defaults and their reason, property invariants, method behavior, and which generic
Value/Elementshapes are accepted and why. - Non-trivial APIs get at least one short example in a fenced
swiftblock, ideally a real declaration from this codebase. internalandprivatesymbols get a one-line///when the intent is non-obvious. The public boundary is the one that needs full coverage.- Update the doc comment in the same edit that changes behavior or signature. Doc comments describe the contract from outside; inline
//is reserved for non-obvious why.
Main app target code is not retroactively required to be documented.
Package design discipline
- No shared-singleton accessors.
static let standard/shared/defaulton a package type holding runtime state is a singleton by another name. Construct at the app startup site and inject.static letis fine for declarations (identifiers, schema entries, enum cases), not for behavior. - No namespace-enums.
enum Foo { static func bar() }is a fake namespace with no instances, no DI, and no test seam. Prefer a value-typed struct passed via constructor when the helper might gain configuration. - No parallel hand-maintained registries. When a list mirrors declared items (
catalog.allmirroring stored properties), derive it viaMirrorreflection or a macro. Two sources of truth drift silently. - Prefer compile-time invariants to runtime traps.
guard ... else { assertionFailure(...); return default }for a "programmer error" case should be encoded in the type system (phantom types, separate concrete flavors). Runtime traps become silent fallbacks in release builds. - No free functions. Top-level
funcdeclarations, any visibility including file-scopeprivate func, are banned; scope functionality to the entity that owns the responsibility. The only sanctioned exception is a@convention(c)trampoline a C API forces, with a one-line justification.
Testability
Every public type added to Packages/ must be testable from a test target without launching the app target, booting AppKit, or depending on the user's filesystem or UserDefaults.standard.
UserDefaults,FileManager, on-disk paths, environment variables, and clocks arrive throughinitparameters. Tests pass aUserDefaults(suiteName:)scoped to the test, a temp directory URL, a fixedDate.- No implementation hardcodes
.shared/.standard. - No static test hooks. A
nonisolated(unsafe) static var fooForTesting(or any global mutable override a test swaps in) leaks across tests and usually needs a lock. Replace it with a protocol seam throughinit, e.g.init(commandRunner: any CommandRunning = CommandRunner()). Deleting the static hook and its lock is part of the extraction, not a follow-up. - Prefer returning the changed value and letting the caller persist over mutating global state and returning
Void. - Surface observation as
AsyncStreamso tests can assert the yielded sequence, rather thanNotificationCenter-only patterns that need a runloop spin. - Show the test-instantiation pattern in the package
README.mdor DocC catalog.
If a design is hard to test, it is wrong. Reach for the constructor parameter list, not the test bench.
Swift 6 concurrency
New code in Packages/, new app-target files, and meaningful rewrites use actor, async/await, AsyncStream/AsyncSequence, @Observable, and @MainActor.
Forbidden without a written justification in the PR description:
- Locks:
NSLock,NSRecursiveLock,os_unfair_lock,OSAllocatedUnfairLock,pthread_mutex_t,Synchronization.Mutex,DispatchSemaphoreused as a lock. Ongoing mutable shared state belongs in anactorwithasyncreads and writes. - KVO via
NSObjectsubclassing to overrideobserveValue(forKeyPath:...)or calladdObserver(_:forKeyPath:...). UseNotificationCenter.default.notifications(named:)or theNSKeyValueObservationtoken API at the seam only. DispatchQueueas a synchronization primitive (queue.sync { ... }serializing mutable state). Queues are fine for event delivery, not for protecting state.- Combine for change propagation:
@Published,ObservableObject,PassthroughSubject/CurrentValueSubject,AnyCancellable. - Completion-handler public APIs (
(Result<T, Error>) -> Void,(T?, Error?) -> Void). Useasync throws -> T; wrap a legacy callback withwithCheckedContinuation/withCheckedThrowingContinuationconfined to that one seam. DispatchQueue.main.async. Annotate the destination@MainActorandawaitit.- Sleeping as a synchronization substitute: any sleep used to poll for a condition, let state settle before reading, or race a callback/animation.
DispatchQueue.asyncAfteris banned outright (not cancellable by structure, not testable). - A single-method
actorused as a mutex.actor Guard { func claim() -> Bool }forces synchronous callers (aProcesstermination handler, aDispatchSourceevent handler, awithCheckedContinuationresume race) throughTask { await guard.claim() }, adding suspension points, ordering hops, and reentrancy surface to a fundamentally synchronous compare-and-set. Use the lock carve-out instead.
Required shape: mutable shared state to an actor with async accessors and an AsyncStream for observers; SwiftUI-render-friendly state to an @Observable @MainActor view model subscribing to that stream and projecting snapshots (never read actor state synchronously from view code); cross-process and cross-thread invariants expressed through actor isolation; new public observable surfaces as AsyncStream/AsyncSequence.
Carve-outs, each with a one-line justification comment on the declaration and hidden behind an AsyncStream or actor surface so callers never see them:
DispatchSource.makeFileSystemObjectSourcefor file watching,makeReadSource/makeWriteSourcefor low-level socket I/O (no async-native replacement).- A bounded, cancellable
Clock.sleep(preferred) orTask.sleepfor a genuine delay or deadline that is itself the intended behavior (minimum display duration, auto-dismiss, check timeout). Drive it from an injectedClockso tests advance virtual time, store theTask, and cancel it on the relevant lifecycle transition. Never to poll, settle, or race. DispatchSource.makeTimerSource(one-shot) only when a genuine deadline must fire outside any async context, in a non-asynctype with noTaskto host the sleep. PreferClock.sleepwhenever the code is already async or actor-isolated; a raw timer is not cancellation-integrated or testable and has suspend/resume/cancel footguns.- A lock for a short, non-blocking synchronous compare-and-set called from non-async callbacks. Canonical case: several synchronous
Process/DispatchSourcecallbacks race to resume onewithCheckedContinuationexactly once, guarded byOSAllocatedUnfairLock(initialState:)over aBool. Not for guarding ongoing domain state. NSKeyValueObservationtoken when wrapping a Foundation/AppKit type that exposes change only via KVO.
When extracting existing code that uses a forbidden primitive, reshape it at the seam instead of copying it; usually it wants an actor. Drain Process pipes concurrently on detached tasks keyed by the raw fd (Int32 is Sendable, FileHandle is not).
@unchecked Sendable and nonisolated(unsafe) require a comment explaining the safety argument, or the diff is rejected. @unchecked Sendable on an entire actor or struct is almost always wrong; prefer nonisolated(unsafe) let on the single non-Sendable property.
Existing app-target code may keep the old primitives until rewritten. Do not retrofit blindly.
Detailed references
- references/package-boundaries.md: extraction order, dependency graph, composition root, pbxproj wiring.
- references/concurrency-carveouts.md: carve-out examples and the reviewer reject-list.
- references/file-api-discipline.md: one-type-per-file, DocC, design smells.





