FOSMVVM ViewModel Generator
Read
shared/functional-discipline.mdbefore proceeding. Every rule below derives from it.
Generate ViewModels following FOSMVVM architecture patterns.
Conceptual Foundation
For full architecture context, see FOSMVVMArchitecture.md | OpenClaw reference
API catalog: check
../shared/api-catalog/FOSMVVM.md§ Protocols, § Localization, § Macros and../shared/api-catalog/FOSFoundation.md§ Coding (Stubbable) before hand-writing helpers.
A ViewModel is the bridge in the Model-View-ViewModel architecture:
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ Model │ ───► │ ViewModel │ ───► │ View │
│ (Data) │ │ (The Bridge) │ │ (SwiftUI) │
└─────────────┘ └─────────────────┘ └─────────────┘
Key insight: In FOSMVVM, ViewModels are:
- Created by a Factory (either server-side or client-side)
- Localized during encoding (resolves all
@LocalizedStringreferences) - Consumed by Views which just render the localized data
First Decision: Hosting Mode
This is a per-ViewModel decision. An app can mix both modes - for example, a standalone iPhone app with server-based sign-in.
The key question: Where does THIS ViewModel's data come from?
| Data Source | Hosting Mode | Factory |
|---|---|---|
| Server/Database | Server-Hosted | Hand-written |
| Local state/preferences | Client-Hosted | Macro-generated |
| ResponseError (caught error) | Client-Hosted | Macro-generated |
Server-Hosted Mode
When data comes from a server:
- Factory is hand-written on server (
ViewModelFactoryprotocol) - Factory queries database, builds ViewModel
- Server localizes during JSON encoding
- Client receives fully localized ViewModel
Examples: Sign-in screen, user profile from API, dashboard with server data
Client-Hosted Mode
When data is local to the device:
- Use
@ViewModel(options: [.clientHostedFactory]) - Macro auto-generates factory from init parameters
- Client bundles YAML resources
- Client localizes during encoding
Examples: Settings screen, onboarding, offline-first features, error display
Error Display Pattern
Error display is a classic client-hosted scenario. You already have the data from ResponseError - just wrap it in a specific ViewModel for that error:
// Specific ViewModel for IdeaMoveRequest errors
@ViewModel(options: [.clientHostedFactory])
struct IdeaMoveErrorViewModel {
let message: LocalizableString
let errorCode: String
public var vmId: ViewModelId = .init(type: Self.self) // shown once — singleton
// Takes the specific ResponseError
init(responseError: IdeaMoveRequest.ResponseError) {
self.message = responseError.message
self.errorCode = responseError.code.rawValue
}
}
Usage:
catch let error as IdeaMoveRequest.ResponseError {
let vm = IdeaMoveErrorViewModel(responseError: error)
return try await req.view.render("Shared/ToastView", vm)
}
Each error scenario gets its own ViewModel:
IdeaMoveErrorViewModelforIdeaMoveRequest.ResponseErrorCreateIdeaErrorViewModelforCreateIdeaRequest.ResponseErrorSettingsValidationErrorViewModelfor settings form errors
Don't create a generic "ToastViewModel" or "ErrorViewModel" - that's unified error architecture, which we avoid.
Key insights:
- No server request needed - you already caught the error
- The
LocalizableStringproperties inResponseErrorare already localized (server did it) - Standard ViewModel → View encoding chain handles this correctly; already-localized strings pass through unchanged
- Client-hosted ViewModel wraps existing data; the macro generates the factory
Hybrid Apps
Many apps use both:
┌───────────────────────────────────────────────┐
│ iPhone App │
├───────────────────────────────────────────────┤
│ SettingsViewModel → Client-Hosted │
│ OnboardingViewModel → Client-Hosted │
│ IdeaMoveErrorViewModel → Client-Hosted │ ← Error display
│ SignInViewModel → Server-Hosted │
│ UserProfileViewModel → Server-Hosted │
└───────────────────────────────────────────────┘
Same ViewModel patterns work in both modes - only the factory creation differs.
Core Responsibility: Shaping Data
A ViewModel's job is shaping data for presentation. This happens in two places:
- Factory - what data is needed, how to transform it
- Localization - how to present it in context (including locale-aware ordering)
The View just renders - it should never compose, format, or reorder ViewModel properties.
What a ViewModel Contains
A ViewModel answers: "What does the View need to display?"
| Content Type | How It's Represented | Example |
|---|---|---|
| Static UI text | @LocalizedString |
Page titles, button labels (fixed text) |
| Dynamic enum values | LocalizableString (stored) |
Status/state display (see Enum Localization Pattern) |
| Dynamic data in text | @LocalizedSubs |
"Welcome, %{name}!" with substitutions |
| Composed text | @LocalizedCompoundString |
Full name from pieces (locale-aware order) |
| Formatted dates | LocalizableDate |
createdAt: LocalizableDate |
| Formatted numbers | LocalizableInt |
totalCount: LocalizableInt |
| Dynamic data | Plain properties | content: String, count: Int |
| Locale-independent value (version, hostname, identity) | Typed property — NOT localized | version: SystemVersion (View renders via .versionString), host: String |
| Nested components | Child ViewModels | cards: [CardViewModel] |
A version or an identity/hostname is NOT localizable text — do not wrap it in
LocalizableString. A contract/release version is aSystemVersion(the View renders it with.versionString); a hostname or other machine identity is a plainString. These values are the same in every locale, so localizing them is a category error — it adds a translation key that can never legitimately differ, and (for a version) throws away the typed comparison FOSMVVM relies on. The default reflex to "localize every string-ish field" is wrong here: localize human-facing text; type everything else.
What a ViewModel Does NOT Contain
- Database relationships (
@Parent,@Siblings) - Business logic or validation (that's in Fields protocols)
- Raw database IDs exposed to templates (use typed properties)
- Unlocalized strings that Views must look up
- Domain / wire types (a
DataModel/Channeltype as a property or init param) — see below
The ViewModel Module Must NOT Depend on Domain Types (Dependency Inversion) — HARD RULE
The ViewModel module never imports the domain/wire module. A ViewModel target
({App}ViewModels, client-facing) depends on FOSMVVM + Foundation + simple or
ViewModel-owned types only — not on the DataModel/Channel module. A domain type as a
ViewModel field is a category error, not merely an unwanted dependency: a ViewModel
is a projection of the data, never the data itself. A field like guestOS: Platform
(where Platform is a Channel domain type) is wrong on its face — and once the domain
import is correctly absent, it won't even compile.
This is Dependency Inversion: the high-level projection (ViewModel) does not depend on
the low-level wire detail (Channel). Get it wrong and FOSMVVM breaks in the ways the
firm principles warn about — leaked persistence types, existential-shaped seams, and a
client module that drags server/host-only code onto iOS.
How to model a domain value correctly:
- The ViewModel init takes simple types (
String,Int, a ViewModel-owned enum) — never a domain type. - For a value the View switches on, define a ViewModel-owned display enum (raw-less,
per the Enum Localization Pattern) — e.g. a
BerthLivenessin the ViewModel module, distinct from any same-named domain type (see Naming Dictionary). - The
ViewModelFactoryperforms the projection. It is the one component that imports both the domain module and the ViewModel module, and it maps domain → display (Channel.Platform → GuestPlatform) when building the VM. Factories are server-side; the ViewModel module stays domain-free.
SOLID ergonomic (optional). The Factory's own library may add a private/internal
extension on the ViewModel with a domain-typed initializer that maps domain → simple
and calls the public simple init:
// In the SERVER/Factory module only — never in the ViewModel module:
extension NodeViewModel {
init(_ node: Channel.Node) { // domain-typed convenience init
self.init(host: node.hostname, // → public simple init
guestOS: GuestPlatform(node.platform))
}
}
The adaptation lives with the adapter; the ViewModel's public API stays domain-free. This is the dual of keeping wire types FOSMVVM-free — the boundary is clean in both directions.
Anti-Pattern: Composition in Views
// ❌ WRONG - View is composing
Text(viewModel.firstName) + Text(" ") + Text(viewModel.lastName)
// ✅ RIGHT - ViewModel provides shaped result
Text(viewModel.fullName) // via @LocalizedCompoundString
If you see + or string interpolation in a View, the shaping belongs in the ViewModel.
ViewModel Protocol Hierarchy
public protocol ViewModel: ServerRequestBody, RetrievablePropertyNames, Identifiable, Stubbable {
var vmId: ViewModelId { get }
}
public protocol RequestableViewModel: ViewModel {
associatedtype Request: ViewModelRequest
}
ViewModel provides:
ServerRequestBody- Can be sent over HTTP as JSONRetrievablePropertyNames- Enables@LocalizedStringbinding (via@ViewModelmacro)Identifiable- HasvmIdfor SwiftUI identityStubbable- Hasstub()for testing/previews
RequestableViewModel adds:
- Associated
Requesttype for fetching from server
Two Categories of ViewModels
1. Top-Level (RequestableViewModel)
Represents a full page or screen. Has:
- An associated
ViewModelRequesttype - A
ViewModelFactorythat builds it from database - Child ViewModels embedded within it
@ViewModel
public struct DashboardViewModel: RequestableViewModel {
public typealias Request = DashboardRequest
@LocalizedString public var pageTitle
public let cards: [CardViewModel] // Children
public var vmId: ViewModelId = .init(type: Self.self) // singleton — one per screen
}
One top-level VM per page/screen, composing children — never a mega-VM. A multi-section surface (dashboard, dock detail, settings) is a top-level
RequestableViewModelthat composes child VMs (cards: [CardViewModel]), one child per section. Do not flatten a many-section screen into a single giant ViewModel: that fuses independent concerns into one type (an SRP violation) and makes every section share one localization/versioning/identity surface.Scaffold one file per VM type — the top-level VM and every composed child each in its own file, grouped in a container-named directory. Full rules: app-setup → File Organization Conventions.
2. Child (plain ViewModel)
Nested components built by their parent's factory. No Request type.
@ViewModel
public struct CardViewModel {
public let id: ModelIdType
public let title: String
public let createdAt: LocalizableDate
public let vmId: ViewModelId // instance (list row) — stable id from data
// Init takes PLAIN Swift types; the init wraps them + owns formatting.
public init(id: ModelIdType, title: String, createdAt: Date) {
self.id = id
self.title = title
self.createdAt = LocalizableDate(value: createdAt)
self.vmId = .init(id: id) // per-row stable — NEVER .init() on a list row
}
}
vmIdderives from the data's identity — bind it, don't reach past it. The row'svmIdis built from the model's own id (.init(id: id)), so equal data ⇒ stable SwiftUI identity. When the identity value is itself a sealed/opaque type, get thevmIdfrom a computed on that identity (it reads its own fields and vends aViewModelId) — never expose the identity's raw string to build the token yourself, and never provide two spellings of the derivation. See Architecture Patterns → Derive on the Owner.
Don't restate
Codable/Sendable— the macro adds them.@ViewModelsynthesizesViewModelconformance, which already providesCodableandSendable. A child VM is just@ViewModel public struct X { … }— no conformance clause. Only add a clause for a conformance the macro does not supply: a top-level requestable VM adds: RequestableViewModel, a form VM adds itsFieldsprotocol, and a genuinely-needed extra like: Identifiablestays. RestatingCodable, Sendableis redundant noise (DRY — don't repeat what the macro guarantees).
Display vs Form ViewModels
ViewModels serve two distinct purposes:
| Purpose | ViewModel Type | Adopts Fields? |
|---|---|---|
| Display data (read-only) | Display ViewModel | No |
| Collect user input (editable) | Form ViewModel | Yes |
Display ViewModels
For showing data - cards, rows, lists, detail views:
@ViewModel
public struct UserCardViewModel {
public let id: ModelIdType
public let name: String
@LocalizedString public var roleDisplayName
public let createdAt: LocalizableDate
public let vmId: ViewModelId // instance (list row) — stable id from data
public init(id: ModelIdType, name: String, createdAt: Date) {
self.id = id
self.name = name
self.createdAt = LocalizableDate(value: createdAt) // init wraps the plain Date
self.vmId = .init(id: id)
// roleDisplayName is @LocalizedString — bound by the macro, not set here
}
}
Characteristics:
- Properties are
let(read-only) - No validation needed
- No FormField definitions
- Just projects Model data for display
Form ViewModels
For collecting input - create forms, edit forms, settings:
@ViewModel
public struct UserFormViewModel: UserFields { // ← Adopts Fields!
public var id: ModelIdType?
public var email: String
public var firstName: String
public var lastName: String
public let userValidationMessages: UserFieldsMessages
public var vmId: ViewModelId = .init(type: Self.self) // one form per screen — singleton
}
Characteristics:
- Properties are
var(editable) - Adopts a Fields protocol for validation
- Gets FormField definitions from Fields
- Gets validation logic from Fields
- Gets localized error messages from Fields
The Connection
┌─────────────────────────────────────────────────────────────────┐
│ UserFields Protocol │
│ (defines editable properties + validation) │
│ │
│ Adopted by: │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ CreateUserReq │ │ UserFormVM │ │ User (Model) │ │
│ │ .RequestBody │ │ (UI form) │ │ (persistence) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ Same validation logic everywhere! │
└─────────────────────────────────────────────────────────────────┘
Quick Decision Guide
The key question: "Is the user editing data in this ViewModel?"
- No → Display ViewModel (no Fields)
- Yes → Form ViewModel (adopt Fields)
| ViewModel | User Edits? | Adopt Fields? |
|---|---|---|
UserCardViewModel |
No | No |
UserRowViewModel |
No | No |
UserDetailViewModel |
No | No |
UserFormViewModel |
Yes | UserFields |
CreateUserViewModel |
Yes | UserFields |
EditUserViewModel |
Yes | UserFields |
SettingsViewModel |
Yes | SettingsFields |
Third Decision: Interactive vs Display-Only
This is a per-ViewModel decision, independent of hosting mode.
The key question: Does the user initiate actions through this ViewModel's view?
| View behavior | ViewModel kind | Operations file generated? |
|---|---|---|
| Renders data only — no user actions | Display-only | No |
| Has buttons, forms, toggles, menus, drag-and-drop | Interactive | Yes |
Interactive ViewModels have a companion Operations file ({Name}ViewModelOperations.swift), co-located with the ViewModel. Display-only ViewModels have no Operations at all — do not invent an empty protocol to satisfy a generic parameter. The test base class for display-only views is ViewModelDisplayTestCase<VM>, which takes no Operations type.
Decision Examples
| VM | Interactive? | Rationale |
|---|---|---|
UserCardViewModel |
No | Renders user data |
UserRowViewModel |
No | Renders list row |
DashboardViewModel |
No | Renders a grid of children |
UserFormViewModel |
Yes | Save/Cancel buttons |
SettingsViewModel |
Yes | Toggles and pickers |
DeviceConnectionViewModel |
Yes | Connect/Disconnect actions |
What "Operations" Is
Operations is the dispatch seam for user-initiated actions. Every interactive ViewModel has:
- Protocol (
{Name}ViewModelOperations: ViewModelOperations) — declares the actions the View can dispatch. - Live implementation (
{Name}Ops, struct) — does the real work: calls a server viaServerRequest, mutates@Observablestorage, talks to a device, etc. - Stub implementation (
{Name}StubOps,final class,@unchecked Sendable) — records which methods were called and with what arguments, for UI tests. A stub records; it never performs the operation's work (ratified 2026-08-25): noawaiton real calls, noTask.sleep, no network or storage reach — a UI test proves the button is wired to the operation, not that the operation does something, and a stub that "does work" turns that wiring test into a timing-dependent behavior test. (Writing theoutputstorage it is handed is recording's client-hosted twin, not work; anasync throwssignature with noawaitis the protocol's shape, not a smell.) - Wiring on the VM — a private
isStub: Boolflag plus apublic var operations: any {Name}ViewModelOperationscomputed property that returns Ops in production and StubOps instub().
The protocol + both implementations live together in {Name}ViewModelOperations.swift, next to {Name}ViewModel.swift.
Operations Conventions: Client-Hosted vs Server-Backed
Operations split along the same hosting axis as the ViewModel. The canonical rules live in Architecture Patterns → Ops Conventions. The short summary:
Client-hosted ops. Mutate one or more @Observable storage objects the View holds in @Environment. Each mutating method takes scalar inputs first and the write target last, labeled output:
func setTheme(_ theme: Theme, output storage: UserSettings)
The View reads storage from @Environment(UserSettings.self) and hands it to the op at the call site:
Button("Dark") {
viewModel.operations.setTheme(.dark, output: settings)
}
Server-backed ops. The server owns storage (database, via Vapor request context). Ops dispatch a ServerRequest and never take an output: parameter:
func disconnect(deviceId: String) async throws
Two rules that apply to both:
asynconly when the body awaits. Do not mark opsasyncspeculatively. Anasynccall site becomesTask { try await op(...) }; for a body that just mutates state, that introduces arbitrary Task completion ordering — rapid user taps can land out of order and the last write isn't always the last tap. Markasynconly for genuine I/O (network, device, disk).- Never fail silently. No
try?, no emptycatch {}. Surface errors to observable state or a logger. See Architecture Patterns → Never Fail Silently for the full rationale.
Full reasoning — the asymmetry between client and server storage, why in storage: is wrong, projection-edge mechanics — lives in Architecture Patterns → Ops Conventions.
Full Server-Hosted Interactive Example
ViewModel file — {ViewModelsTarget}/Info/InfoViewModel.swift:
@ViewModel
public struct InfoViewModel: RequestableViewModel {
// MARK: ViewModel Properties
@LocalizedString public var connectionTitle
@LocalizedString public var disconnectTitle
public let deviceId: String
// MARK: RequestableViewModel Protocol
public typealias Request = InfoRequest
public let vmId: ViewModelId
// MARK: Operations Access
private let isStub: Bool
#if canImport(SwiftUI)
public var operations: any InfoViewModelOperations {
isStub ? InfoStubOps() : InfoOps()
}
#endif
// MARK: Initialization
public init(deviceId: String) {
self.init(isStub: false, deviceId: deviceId)
}
private init(isStub: Bool, deviceId: String) {
self.isStub = isStub
self.deviceId = deviceId
self.vmId = .init(type: Self.self)
}
public static func stub() -> Self {
.init(isStub: true, deviceId: "test-device")
}
}
Operations file — {ViewModelsTarget}/Info/InfoViewModelOperations.swift:
import FOSFoundation
import FOSMVVM
import Foundation
// MARK: - Protocol
public protocol InfoViewModelOperations: ViewModelOperations {
func disconnect(deviceId: String) async throws
}
// MARK: - Live Implementation (Server-Backed)
public struct InfoOps: InfoViewModelOperations {
public init() {}
public func disconnect(deviceId: String) async throws {
// Dispatches a ServerRequest. The server owns storage;
// no `output:` parameter. `async throws` matches the network call.
}
}
// MARK: - Stub Implementation
#if canImport(SwiftUI)
public final class InfoStubOps: InfoViewModelOperations, @unchecked Sendable {
public var disconnectCalled: Bool { disconnectCalledWith != nil }
public private(set) var disconnectCalledWith: String?
public init() {}
public func disconnect(deviceId: String) async throws {
disconnectCalledWith = deviceId
}
}
#endif
No output storage: on any method — the server owns storage. The async throws is genuine (network I/O). The stub exposes two assertion points: disconnectCalled (did the op fire at all?) and disconnectCalledWith (was the right data passed?).
Full Client-Hosted Interactive Example
ViewModel file — {ViewModelsTarget}/Preferences/PreferencesViewModel.swift:
@ViewModel(options: [.clientHostedFactory])
public struct PreferencesViewModel {
// MARK: ViewModel Properties
@LocalizedString public var pageTitle
@LocalizedString public var darkModeLabel
// Scalar projections from @Observable storage (see architecture-patterns.md)
public let notificationsEnabled: Bool
public let theme: Theme
// MARK: Operations Access
private let isStub: Bool
#if canImport(SwiftUI)
public var operations: any PreferencesViewModelOperations {
isStub ? PreferencesStubOps() : PreferencesOps()
}
#endif
public var vmId: ViewModelId = .init(type: Self.self) // singleton page VM
// MARK: Initialization
// Public init parameters become AppState properties (macro-generated).
// Do NOT include isStub here — it's an implementation detail, not AppState.
public init(notificationsEnabled: Bool, theme: Theme) {
self.init(isStub: false, notificationsEnabled: notificationsEnabled, theme: theme)
}
private init(isStub: Bool, notificationsEnabled: Bool, theme: Theme) {
self.isStub = isStub
self.notificationsEnabled = notificationsEnabled
self.theme = theme
}
public static func stub() -> Self {
.init(isStub: true, notificationsEnabled: false, theme: .system)
}
}
Operations file — {ViewModelsTarget}/Preferences/PreferencesViewModelOperations.swift:
import FOSFoundation
import FOSMVVM
import Foundation
// MARK: - Protocol
public protocol PreferencesViewModelOperations: ViewModelOperations {
func setTheme(_ theme: Theme, output storage: UserSettings)
func setNotificationsEnabled(_ enabled: Bool, output storage: UserSettings)
}
// MARK: - Live Implementation (Client-Hosted)
public struct PreferencesOps: PreferencesViewModelOperations {
public init() {}
public func setTheme(_ theme: Theme, output storage: UserSettings) {
storage.theme = theme
}
public func setNotificationsEnabled(_ enabled: Bool, output storage: UserSettings) {
storage.notificationsEnabled = enabled
}
}
// MARK: - Stub Implementation
#if canImport(SwiftUI)
public final class PreferencesStubOps: PreferencesViewModelOperations, @unchecked Sendable {
public private(set) var setThemeCalled: Bool = false
public private(set) var setNotificationsEnabledCalled: Bool = false
public init() {}
public func setTheme(_ theme: Theme, output storage: UserSettings) {
setThemeCalled = true
storage.theme = theme
}
public func setNotificationsEnabled(_ enabled: Bool, output storage: UserSettings) {
setNotificationsEnabledCalled = true
storage.notificationsEnabled = enabled
}
}
#endif
Every mutating method takes output storage: UserSettings as its last parameter. Ops are synchronous — bodies do no awaiting. The client-hosted stub records that the op fired (Called: Bool = false) and performs the same mutation the live implementation would — so @Observable fires, the resolver re-projects, and the View updates under test. Tests assert "was it called?" with stubOps.setThemeCalled and "with what value?" by reading storage.theme directly; the storage itself holds the CalledWith equivalent, so no separate accessor is needed.
This asymmetry with server-backed stubs (which expose Called + CalledWith accessors and never mutate) is intentional: server-backed tests have no local storage to observe, so the stub must expose both accessors; client-hosted tests have storage right there, so the stub uses it to keep the projection loop intact.
Note on the AppState/scalar split. The ViewModel holds scalars (notificationsEnabled: Bool, theme: Theme), not a reference to UserSettings. At the call site the View holds @Environment(UserSettings.self) and hands the reference directly to the op — the reference never passes through the VM. See Architecture Patterns → VMs Hold Scalars for why.
← Functional discipline: a captured mutable reference inside a "value" breaks referential transparency — the projection becomes a function of WHEN YOU LOOK, so equality, memoization, and serialization all quietly lie: a closure masquerading as data. A cache that references the thing it caches is not a cache.
When to Use This Skill
- Creating a new page or screen
- Adding a new UI component (card, row, modal, etc.)
- Displaying data from the database in a View
- Following an implementation plan that requires new ViewModels
What This Skill Generates
Interactive ViewModels (those that dispatch user-initiated actions) get an additional {Name}ViewModelOperations.swift file co-located with the ViewModel. Display-only ViewModels do not get this file — no empty protocols, no operation scaffolding. See Third Decision: Interactive vs Display-Only above.
Server-Hosted: Top-Level ViewModel
| File | Location | Purpose | Interactive only? |
|---|---|---|---|
{Name}ViewModel.swift |
{ViewModelsTarget}/ |
The ViewModel struct | No |
{Name}Request.swift |
{ViewModelsTarget}/ |
The ViewModelRequest type | No |
{Name}ViewModel.yml |
{ResourcesPath}/ |
Localization strings | No |
{Name}ViewModel+Factory.swift |
{WebServerTarget}/ |
Factory that builds from DB | No |
{Name}ViewModelOperations.swift |
{ViewModelsTarget}/ |
Ops protocol + live + stub | Yes |
Display-only: 4 files. Interactive: 5 files.
Client-Hosted: Top-Level ViewModel
| File | Location | Purpose | Interactive only? |
|---|---|---|---|
{Name}ViewModel.swift |
{ViewModelsTarget}/ |
ViewModel with clientHostedFactory option |
No |
{Name}ViewModel.yml |
{ResourcesPath}/ |
Localization strings (bundled in app) | No |
{Name}ViewModelOperations.swift |
{ViewModelsTarget}/ |
Ops protocol + live + stub | Yes |
Display-only: 2 files. Interactive: 3 files. No Request or Factory files needed — macro generates them.
Child ViewModels (1-2 files, either mode)
| File | Location | Purpose |
|---|---|---|
{Name}ViewModel.swift |
{ViewModelsTarget}/ |
The ViewModel struct |
{Name}ViewModel.yml |
{ResourcesPath}/ |
Localization (if has @LocalizedString) |
Child ViewModels don't own Operations — if a child's rendering has actions, those dispatch through the top-level VM's Operations, or the child is promoted to a top-level ViewModel with its own Operations file.
Note: If child is only used by one parent and represents a summary/reference (not a full ViewModel), nest it inside the parent file instead. See Nested Child Types Pattern under Key Patterns.
Project Structure Configuration
| Placeholder | Description | Example |
|---|---|---|
{ViewModelsTarget} |
Shared ViewModels SPM target | ViewModels |
{ResourcesPath} |
Localization resources | Sources/Resources |
{WebServerTarget} |
Server-side target | WebServer, AppServer |
How to Use This Skill
Invocation: /fosmvvm-viewmodel-generator
Prerequisites:
- View requirements understood from conversation context
- Data source determined (server/database vs local state)
- Display vs Form decision made (if user input involved, Fields protocol exists)
Workflow integration: This skill is typically used after discussing View requirements or reading specification files. The skill references conversation context automatically—no file paths or Q&A needed. For Form ViewModels, run fosmvvm-fields-generator first to create the Fields protocol.
Pattern Implementation
This skill references conversation context to determine ViewModel structure:
Hosting Mode Detection
From conversation context, the skill identifies:
- Data source (server/database vs local state/preferences)
- Server-hosted → Hand-written factory, server-side localization
- Client-hosted → Macro-generated factory, client-side localization
ViewModel Design
From requirements already in context:
- View purpose (page, modal, card, row component)
- Data needs (from database query, from AppState, from caught error)
- Static UI text (titles, labels, buttons requiring @LocalizedString)
- Child ViewModels (nested components)
- Hierarchy level (top-level RequestableViewModel vs child ViewModel)
Property Planning
Based on View requirements:
- Display properties (data to render)
- Localization requirements (which properties use @LocalizedString)
- Identity strategy (singleton vmId vs instance-based vmId)
- Form adoption (whether ViewModel adopts Fields protocol)
File Generation
Server-Hosted Top-Level:
- ViewModel struct (with
RequestableViewModel) - Request type
- YAML localization
- Factory implementation
Client-Hosted Top-Level:
- ViewModel struct (with
clientHostedFactoryoption) - YAML localization
Child (either mode):
- ViewModel struct
- YAML localization (if needed)
Context Sources
Skill references information from:
- Prior conversation: View requirements, data sources discussed with user
- Specification files: If Claude has read UI specs or feature docs into context
- Fields protocols: From codebase or previous fosmvvm-fields-generator invocation
Key Patterns
The @ViewModel Macro
Always use the @ViewModel macro - it generates the propertyNames() method required for localization binding.
Server-Hosted (basic macro):
@ViewModel
public struct MyViewModel: RequestableViewModel {
public typealias Request = MyRequest
@LocalizedString public var title
public var vmId: ViewModelId = .init(type: Self.self) // singleton
public init() {}
}
Client-Hosted (with factory generation):
@ViewModel(options: [.clientHostedFactory])
public struct SettingsViewModel {
@LocalizedString public var pageTitle
public var vmId: ViewModelId = .init(type: Self.self) // singleton
public init(theme: Theme, notifications: NotificationSettings) {
// Init parameters become AppState properties
}
}
// Macro auto-generates:
// - typealias Request = ClientHostedRequest
// - struct AppState { let theme: Theme; let notifications: NotificationSettings }
// - class ClientHostedRequest: ViewModelRequest { ... }
// - static func model(context:) async throws -> Self { ... }
Interactive variants. Both examples above are display-only. Interactive ViewModels add an isStub: Bool flag, a public var operations: any ... computed property, and a private init(isStub:, ...) that the public init and stub() both delegate to. Full shape (both server-hosted and client-hosted): see Third Decision: Interactive vs Display-Only above.
Stubbable Pattern
All ViewModels must satisfy the Stubbable witness stub() for testing and SwiftUI previews. With @ViewModel you rarely hand-write the zero-arg stub(): write a fully-defaulted parameterized stub(...) in the type's body and the macro synthesizes the zero-arg witness, forwarding the defaults.
@ViewModel
public struct MyViewModel: RequestableViewModel {
public let id: ModelIdType
@LocalizedString public var title
public let vmId: ViewModelId
public init(id: ModelIdType, /* … */) { /* … */ }
// The fully-defaulted parameterized stub lives IN THE TYPE BODY so `@ViewModel`
// can see it and synthesize the zero-arg `stub()` Stubbable witness from it.
public static func stub(
id: ModelIdType = .init(),
title: String = "Sample"
) -> Self {
.init(id: id, title: title)
}
}
The parameterized
stub(...)must be in the type's body — NOT in anextension.@ViewModelis a member macro: Swift hands it only the struct declaration, so astub(...)sitting inextension MyViewModel { … }is invisible to it and no witness is synthesized →does not conform to 'Stubbable'. (A hand-written zero-argstub()may live in an extension — it's a real witness — but astub(...)you expect the macro to forward to cannot.)
Hand-write the zero-arg stub() yourself only when the macro has nothing to forward to: a no-argument init() (stub() { .init() }), an interactive VM whose stub routes through a private init(isStub:), or a nested type that is plain Stubbable without @ViewModel (see Two-Tier Stubbable Pattern). A parameterized stub(...) with any non-defaulted parameter is also not forwardable — the macro leaves such types to surface the normal Stubbable conformance error.
Identity: vmId — stable data identity, never a throwaway
vmId parameterizes SwiftUI's .id() on the view that renders the ViewModel, so it
governs view stabilization (whether SwiftUI reuses or tears down the view on refresh). It
must be stable across re-fetches of the same logical thing — never a fresh random
value.
The rule: the vmId must uniquely identify this ViewModel from the values it was
projected from. Everything below follows from that — including why "I used an init
parameter" is not on its own an answer.
1. An id init parameter, if one exists. userId, groupId, companyId, agentID
— the identity the data already carries:
self.vmId = .init(id: userId)
2. Otherwise, a value derived from the init parameters that uniquely identifies this projection. Compose the parameters that together distinguish it, or hash across all of them:
self.vmId = .init(id: "\(host)-\(version.versionString)") // composed
3. Type based — .init(type: Self.self) — when the ViewModel is singleton in identity.
Not a separate strategy so much as rule 2's degenerate case: when the parameter values are
always the same, the type is what uniquely identifies it. One instance per screen.
4. Random — a bare ViewModelId() / .init(). Almost never desirable. It is random
under the hood (isRandom, String.unique()), so every re-fetch mints a new identity,
SwiftUI treats the view as new, and it tears down and rebuilds — taking selection, scroll
position, and focus with it. Reach for it only where identity genuinely cannot matter, and
expect to justify it.
Using an init parameter is not the test — uniqueness is. A display label is an init parameter and identifies nothing: two Docks the operator named "Studio A" collide on one
vmId, and renaming one churns its row. If the init also carries a real id, that is the one to use; if it does not, compose or hash the parameters that actually distinguish the projection.
Note that ViewModelId stores isRandom alongside the id: the framework itself treats a
random identity as the exceptional case, not an ordinary one.
Singleton — one instance per screen (a top-level page VM, or a once-only child such as a header/summary panel). Constant per type = maximally stable:
public var vmId: ViewModelId = .init(type: Self.self)
A VM that already has an init may equivalently declare public let vmId: ViewModelId
and assign self.vmId = .init(type: Self.self) in the init. Do not write
let vmId: ViewModelId = .init(type: Self.self) as a property default — an immutable
property with a default is excluded from Codable decoding (the compiler warns); use var
with a default, or let assigned in init.
Instance — many per screen, ESPECIALLY List/ForEach rows. The vmId MUST carry the
row's stable data identity, assigned in init:
public let vmId: ViewModelId
public init(id: SomeId, /* … */) {
// …
self.vmId = .init(id: id) // id may be ModelIdType, String, Int, or UUID
}
Use the data's own id when it has one (user.id, a nodeId: String, …). ViewModelId
accepts a plain String/Int/UUID/ModelIdType — the id is not required to be a
ModelIdType. When there is no single natural id, merge stable init args into one:
self.vmId = .init(id: "\(version.versionString)-\(host)")
Two failure modes on
Listrows — both churn identity / tear the view down on every refresh:
- a bare
.init()→ a new random id each fetch, so SwiftUI treats every
…(truncated)