Claude-skill-registry fosmvvm-viewmodel-generator
Generate FOSMVVM ViewModels - the bridge between server-side data and client-side Views. Use when creating new screens, pages, components, or any UI that displays data.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/fosmvvm-viewmodel-generator" ~/.claude/skills/majiayu000-claude-skill-registry-fosmvvm-viewmodel-generator && rm -rf "$T"
skills/data/fosmvvm-viewmodel-generator/SKILL.mdFOSMVVM ViewModel Generator
Generate ViewModels following FOSMVVM architecture patterns.
Conceptual Foundation
For full architecture context, see FOSMVVMArchitecture.md
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
references)@LocalizedString - 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 (
protocol)ViewModelFactory - 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 MoveIdeaRequest errors @ViewModel(options: [.clientHostedFactory]) struct MoveIdeaErrorViewModel { let message: LocalizableString let errorCode: String public var vmId = ViewModelId() // Takes the specific ResponseError init(responseError: MoveIdeaRequest.ResponseError) { self.message = responseError.message self.errorCode = responseError.code.rawValue } }
Usage:
catch let error as MoveIdeaRequest.ResponseError { let vm = MoveIdeaErrorViewModel(responseError: error) return try await req.view.render("Shared/ToastView", vm) }
Each error scenario gets its own ViewModel:
forMoveIdeaErrorViewModelMoveIdeaRequest.ResponseError
forCreateIdeaErrorViewModelCreateIdeaRequest.ResponseError
for settings form errorsSettingsValidationErrorViewModel
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
properties inLocalizableString
are already localized (server did it)ResponseError - 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 │ │ MoveIdeaErrorViewModel → 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 | | Page titles, button labels (fixed text) |
| Dynamic enum values | (stored) | Status/state display (see Enum Localization Pattern) |
| Dynamic data in text | | "Welcome, %{name}!" with substitutions |
| Composed text | | Full name from pieces (locale-aware order) |
| Formatted dates | | |
| Formatted numbers | | |
| Dynamic data | Plain properties | , |
| Nested components | Child ViewModels | |
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
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:
- Can be sent over HTTP as JSONServerRequestBody
- EnablesRetrievablePropertyNames
binding (via@LocalizedString
macro)@ViewModel
- HasIdentifiable
for SwiftUI identityvmId
- HasStubbable
for testing/previewsstub()
RequestableViewModel adds:
- Associated
type for fetching from serverRequest
Two Categories of ViewModels
1. Top-Level (RequestableViewModel)
Represents a full page or screen. Has:
- An associated
typeViewModelRequest - A
that builds it from databaseViewModelFactory - 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() }
2. Child (plain ViewModel)
Nested components built by their parent's factory. No Request type.
@ViewModel public struct CardViewModel: Codable, Sendable { public let id: ModelIdType public let title: String public let createdAt: LocalizableDate public var vmId: ViewModelId = .init() }
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 var vmId: ViewModelId = .init() }
Characteristics:
- Properties are
(read-only)let - 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() }
Characteristics:
- Properties are
(editable)var - 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? |
|---|---|---|
| No | No |
| No | No |
| No | No |
| Yes | |
| Yes | |
| Yes | |
| Yes | |
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
Server-Hosted: Top-Level ViewModel (4 files)
| File | Location | Purpose |
|---|---|---|
| | The ViewModel struct |
| | The ViewModelRequest type |
| | Localization strings |
| | Factory that builds from DB |
Client-Hosted: Top-Level ViewModel (2 files)
| File | Location | Purpose |
|---|---|---|
| | ViewModel with option |
| | Localization strings (bundled in app) |
No Request or Factory files needed - macro generates them!
Child ViewModels (1-2 files, either mode)
| File | Location | Purpose |
|---|---|---|
| | The ViewModel struct |
| | Localization (if has ) |
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 |
|---|---|---|
| Shared ViewModels SPM target | |
| Localization resources | |
| Server-side target | , |
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
option)clientHostedFactory - 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() public init() {} }
Client-Hosted (with factory generation):
@ViewModel(options: [.clientHostedFactory]) public struct SettingsViewModel { @LocalizedString public var pageTitle public var vmId: ViewModelId = .init() 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 { ... }
Stubbable Pattern
All ViewModels must support
stub() for testing and SwiftUI previews:
public extension MyViewModel { static func stub() -> Self { .init(/* default values */) } }
Identity: vmId
Every ViewModel needs a
vmId for SwiftUI's identity system:
Singleton (one per page):
vmId = .init(type: Self.self)
Instance (multiple per page): vmId = .init(id: id) where id: ModelIdType
Localization
Static UI text uses
@LocalizedString:
@LocalizedString public var pageTitle
With corresponding YAML:
en: MyViewModel: pageTitle: "Welcome"
Dates and Numbers
Never send pre-formatted strings. Use localizable types:
public let createdAt: LocalizableDate // NOT String public let itemCount: LocalizableInt // NOT String
The client formats these according to user's locale and timezone.
Enum Localization Pattern
For dynamic enum values (status, state, category), use a stored
- NOT LocalizableString
@LocalizedString.
@LocalizedString always looks up the same key (the property name). A stored LocalizableString carries the dynamic key from the enum case.
// Enum provides localizableString public enum SessionState: String, CaseIterable, Codable, Sendable { case pending, running, completed, failed public var localizableString: LocalizableString { .localized(for: Self.self, propertyName: rawValue) } } // ViewModel stores it (NOT @LocalizedString) @ViewModel public struct SessionCardViewModel { public let state: SessionState // Raw enum for data attributes public let stateDisplay: LocalizableString // Localized display text public init(session: Session) { self.state = session.state self.stateDisplay = session.state.localizableString } }
# YAML keys match enum type and case names en: SessionState: pending: "Pending" running: "Running" completed: "Completed" failed: "Failed"
Constraint:
LocalizableString only works in ViewModels encoded with localizingEncoder(). Do not use in Fluent JSONB fields or other persisted types.
Child ViewModels
Top-level ViewModels contain their children:
@ViewModel public struct BoardViewModel: RequestableViewModel { public let columns: [ColumnViewModel] public let cards: [CardViewModel] }
The Factory builds all children when building the parent.
Nested Child Types Pattern
When a child type is only used by one parent and represents a summary or reference (not a full ViewModel), nest it inside the parent:
@ViewModel public struct GovernancePrincipleCardViewModel: Codable, Sendable, Identifiable { // Properties come first public let versionHistory: [GovernancePrincipleVersionSummary]? public let referencingDecisions: [GovernanceDecisionReference]? // MARK: - Nested Types /// Summary of a principle version for display in version history. public struct GovernancePrincipleVersionSummary: Codable, Sendable, Identifiable, Stubbable { public let id: ModelIdType public let version: Int public let createdAt: Date public init(id: ModelIdType, version: Int, createdAt: Date) { self.id = id self.version = version self.createdAt = createdAt } } /// Reference to a decision that cites this principle. public struct GovernanceDecisionReference: Codable, Sendable, Identifiable, Stubbable { public let id: ModelIdType public let title: String public let decisionNumber: String public let createdAt: Date public init(id: ModelIdType, title: String, decisionNumber: String, createdAt: Date) { self.id = id self.title = title self.decisionNumber = decisionNumber self.createdAt = createdAt } } // vmId and parent init follow public let vmId: ViewModelId // ... }
Reference:
Sources/KairosModels/Governance/GovernancePrincipleCardViewModel.swift
Placement rules:
- Nested types go AFTER the properties that reference them
- Before
and the parent's initvmId - Use
section marker// MARK: - Nested Types - Each nested type gets its own doc comment
Conformances for nested types:
- for ViewModel encodingCodable
- for Swift 6 concurrencySendable
- for SwiftUI ForEach if used in arraysIdentifiable
- for testing/previewsStubbable
Two-Tier Stubbable Pattern:
Nested types use fully qualified names in their extensions:
public extension GovernancePrincipleCardViewModel.GovernancePrincipleVersionSummary { // Tier 1: Zero-arg convenience (ALWAYS delegates to tier 2) static func stub() -> Self { .stub(id: .init()) } // Tier 2: Full parameterized with defaults static func stub( id: ModelIdType = .init(), version: Int = 1, createdAt: Date = .now ) -> Self { .init(id: id, version: version, createdAt: createdAt) } } public extension GovernancePrincipleCardViewModel.GovernanceDecisionReference { static func stub() -> Self { .stub(id: .init()) } static func stub( id: ModelIdType = .init(), title: String = "A Title", decisionNumber: String = "DEC-12345", createdAt: Date = .now ) -> Self { .init(id: id, title: title, decisionNumber: decisionNumber, createdAt: createdAt) } }
Why two tiers:
- Tests often just need
without caring about values[.stub()] - Other tests need specific values:
.stub(name: "Specific Name") - Zero-arg ALWAYS calls parameterized version (single source of truth)
When to nest vs keep top-level:
| Nest Inside Parent | Keep Top-Level |
|---|---|
| Child is ONLY used by this parent | Child is shared across multiple parents |
| Child represents subset/summary | Child is a full ViewModel |
| Child has no @ViewModel macro | Child has @ViewModel macro |
| Child is not RequestableViewModel | Child is RequestableViewModel |
| Example: VersionSummary, Reference | Example: CardViewModel, ListViewModel |
Examples:
Card with nested summaries:
@ViewModel public struct TaskCardViewModel { public let assignees: [AssigneeSummary]? public struct AssigneeSummary: Codable, Sendable, Identifiable, Stubbable { public let id: ModelIdType public let name: String public let avatarUrl: String? // ... } }
List with nested references:
@ViewModel public struct ProjectListViewModel { public let relatedProjects: [ProjectReference]? public struct ProjectReference: Codable, Sendable, Identifiable, Stubbable { public let id: ModelIdType public let title: String public let status: String // ... } }
Codable and Computed Properties
Swift's synthesized
Codable only encodes stored properties. Since ViewModels are serialized (for JSON transport, Leaf rendering, etc.), computed properties won't be available.
// Computed - NOT encoded, invisible after serialization public var hasCards: Bool { !cards.isEmpty } // Stored - encoded, available after serialization public let hasCards: Bool
When to pre-compute:
For Leaf templates, you can often use Leaf's built-in functions directly:
- no need for#if(count(cards) > 0)
propertyhasCards
- no need for#count(cards)
propertycardCount
Pre-compute only when:
- Direct array subscripts needed (
- array indexing not documented in Leaf)firstCard - Complex logic that's cleaner in Swift than in template
- Performance-sensitive repeated calculations
See fosmvvm-leaf-view-generator for Leaf template patterns.
File Templates
See reference.md for complete file templates.
Naming Conventions
| Concept | Convention | Example |
|---|---|---|
| ViewModel struct | | |
| Request class | | |
| Factory extension | | |
| YAML file | | |
See Also
- Architecture Patterns - Mental models (errors are data, type safety, etc.)
- FOSMVVMArchitecture.md - Full FOSMVVM architecture
- fosmvvm-fields-generator - For form validation
- fosmvvm-fluent-datamodel-generator - For Fluent persistence layer
- fosmvvm-leaf-view-generator - For Leaf templates that render ViewModels
- reference.md - Complete file templates
Version History
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2024-12-24 | Initial skill |
| 2.0 | 2024-12-26 | Complete rewrite from architecture; generalized from Kairos-specific |
| 2.1 | 2024-12-26 | Added Client-Hosted mode support; per-ViewModel hosting decision |
| 2.2 | 2024-12-26 | Added shaping responsibility, @LocalizedSubs/@LocalizedCompoundString, anti-pattern |
| 2.3 | 2025-12-27 | Added Display vs Form ViewModels section; clarified Fields adoption |
| 2.4 | 2026-01-08 | Added Codable/computed properties section. Clarified when to pre-compute vs use Leaf built-ins. |
| 2.5 | 2026-01-19 | Added Enum Localization Pattern section. Clarified @LocalizedString is for static text only; stored LocalizableString for dynamic enum values. |
| 2.6 | 2026-01-24 | Update to context-aware approach (remove file-parsing/Q&A). Skill references conversation context instead of asking questions or accepting file paths. |
| 2.7 | 2026-01-25 | Added Nested Child Types Pattern section with two-tier Stubbable pattern, placement rules, conformances, and decision criteria for when to nest vs keep top-level. |