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/foscomputerservices/FOSUtilities /tmp/FOSUtilities && cp -r /tmp/FOSUtilities/.claude/skills/fosmvvm-viewmodel-generator ~/.claude/skills/FOSUtilities// tip: Run this command in your terminal to install the skill
name: fosmvvm-viewmodel-generator description: 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.
FOSMVVM 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
@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.
Ask: 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 |
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
Hybrid Apps
Many apps use both:
┌─────────────────────────────────────────┐
│ iPhone App │
├─────────────────────────────────────────┤
│ SettingsViewModel → Client-Hosted │
│ OnboardingViewModel → Client-Hosted │
│ 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 |
| 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 |
| Nested components | Child ViewModels | cards: [CardViewModel] |
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:
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()
}
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
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()
}
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
Ask: "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 |
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 |
|---|---|---|
{Name}ViewModel.swift | {ViewModelsTarget}/ | The ViewModel struct |
{Name}Request.swift | {ViewModelsTarget}/ | The ViewModelRequest type |
{Name}ViewModel.yml | {ResourcesPath}/ | Localization strings |
{Name}ViewModel+Factory.swift | {WebServerTarget}/ | Factory that builds from DB |
Client-Hosted: Top-Level ViewModel (2 files)
| File | Location | Purpose |
|---|---|---|
{Name}ViewModel.swift | {ViewModelsTarget}/ | ViewModel with clientHostedFactory option |
{Name}ViewModel.yml | {ResourcesPath}/ | Localization strings (bundled in app) |
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) |
Project Structure Configuration
| Placeholder | Description | Example |
|---|---|---|
{ViewModelsTarget} | Shared ViewModels SPM target | ViewModels |
{ResourcesPath} | Localization resources | Sources/Resources |
{WebServerTarget} | Server-side target | WebServer, AppServer |
Generation Process
Step 1: Determine Hosting Mode
Ask: Where does this ViewModel's data come from?
- Server/database → Server-Hosted (hand-written factory)
- Local state → Client-Hosted (macro-generated factory)
Step 2: Understand What the View Needs
Clarify:
- What is this View displaying? (page, modal, card, row?)
- What data does it need? (from database? from AppState?)
- What static text does it have? (titles, labels, buttons)
- Does it contain child ViewModels?
- Is it top-level or a child?
Step 3: Design the ViewModel
Determine:
- Properties - What does the View need to render?
- Localization - Which properties are
@LocalizedString? - Identity - Singleton (
vmId = .init(type: Self.self)) or instance (vmId = .init(id: id))?
Step 4: Generate Files
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)
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.
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.
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:
#if(count(cards) > 0)- no need forhasCardsproperty#count(cards)- no need forcardCountproperty
Pre-compute only when:
- Direct array subscripts needed (
firstCard- array indexing not documented in Leaf) - 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 | {Name}ViewModel | DashboardViewModel |
| Request class | {Name}Request | DashboardRequest |
| Factory extension | {Name}ViewModel+Factory.swift | DashboardViewModel+Factory.swift |
| YAML file | {Name}ViewModel.yml | DashboardViewModel.yml |
Collaboration Protocol
- Understand what the View needs to display
- Confirm whether it's top-level or child
- Display or Form? - If form, use fields-generator first to create Fields protocol
- Identify which properties need localization
- Generate files one at a time with feedback
See Also
- 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. |
Repository
