-
Notifications
You must be signed in to change notification settings - Fork 2
MVVM
The DAW app under apps/DeadCat follows the Model-View-ViewModel (MVVM) pattern on Apple's Observation framework: SwiftData models hold the data, @Observable classes hold the state and the logic, and SwiftUI views render whatever those objects say. This page maps the three layers onto the files that implement them and records the rules each layer follows.
| Layer | What it holds | In the code |
|---|---|---|
| Model | Plain data and the relationships between it. No logic beyond what the data itself can answer. |
Models/Session.swift, Models/Take.swift
|
| View model | State the views observe, and every operation that changes it: the document store and the audio engines. |
Store/SessionStore.swift, Audio/TakeRecorder.swift, Audio/TakePlayer.swift
|
| View | SwiftUI structs that read observable state and call methods on it. Presentation state only. |
Views/RootView.swift, Views/RecordControl.swift
|
View models are named for the job they do, not for the view they serve. SessionStore is the document: it owns the SwiftData container and the recording files together, so a take's row and its audio cannot drift apart. TakeRecorder and TakePlayer each wrap one AVAudioEngine concern. There is no RootViewModel; a view observes the objects whose state it shows.
A view reads observable state, a user action calls a method, the method mutates the model, and Observation invalidates exactly the views that read what changed.
// RootView.swift
Button("New Session", systemImage: "plus") {
store.createSession()
}// SessionStore.swift
@discardableResult
func createSession() -> Session {
let session = Session(name: nextDefaultName())
context.insert(session)
save()
refresh()
return session
}The store exposes its state read-only (private(set) var sessions), so the only way anything changes is through a method that also persists the change. Views never touch the ModelContext.
Everything runs on the main actor. Each view model is @MainActor @Observable, which satisfies Swift 6 strict concurrency without locks or manual Sendable conformances; audio work that must leave the main actor stays inside the engine classes.
Dependencies arrive through initializers. The app builds the store at launch and hands it down; views construct and own the engine objects whose lifetime matches theirs:
// DeadCatApp.swift
@main
struct DeadCatApp: App {
@State private var store = DeadCatApp.makeStore()
var body: some Scene {
WindowGroup {
RootView(store: store)
}
}
// ...
}SessionStore has two factories: onDevice() opens the real database and recordings directory under Application Support, and inMemory() opens a throwaway store for previews, tests, and seeded launches. Launching with the -seedSessions argument selects the in-memory store filled with example sessions, so previews, screenshot captures, and manual runs can exercise every list state without touching stored data.
Tests use the same factory directly:
// SessionStoreTests.swift
@Test
func `creating sessions names them sequentially`() {
let store = SessionStore.inMemory()
store.createSession()
store.createSession()
#expect(store.sessions.map(\.name) == ["Session 1", "Session 2"])
}-
Models stay plain. A
@Modelclass carries stored properties, relationships, and computed answers about its own data (Session.firstTake). Persistence, file handling, and naming logic live in the store. -
View models own every mutation. Anything that writes to the database, the file system, or the audio engine is a method on an
@Observableclass, logged throughos.Loggerwhen it fails. State the views read isprivate(set). -
Views hold presentation state and ownership, never data.
@Statein a view either owns an observable object for the view's lifetime (RootViewowns the recorder and player) or holds presentation state like a rename sheet's text or an alert flag. It never duplicates model data; a view that needs data observes the object that owns it. -
Failures never throw into a view. The store logs through
os.Loggerand keeps the last good state; destructive operations order their side effects so a failure cannot lose data (rows delete before their audio files). Failures the user must act on, microphone access and a recording that could not start, surface as alerts from the view that triggered them.
-
ObservableObjectand@Published. Observation replaced them, and no class in the app uses them. - Singletons and
@Environment-injected globals for the object graph. The graph is small and explicit; initializers carry it. - Third-party state management. The app is Swift and Apple frameworks only, like the rest of the repository.
- A view model per view. A new view gets a new
@Observableclass only when it has state and logic of its own to hold.
The architecture page maps where the app sits among the package's modules, and the DAW page carries the milestone path this structure serves.
DeadCat is built by systemBlue. Repo · Releases · Discussions
The DAW
The servers
Reference
Policies