How do you prevent massive ViewModels?
1. Split by responsibility, not by screen
The default failure mode is one ViewModel per screen that accumulates every concern the screen touches: fetching, validation, formatting, navigation triggers, analytics. Instead, decompose along responsibility lines and compose the screen’s ViewModel out of smaller, focused pieces:
final class ProfileViewModel {
private let profileLoader: ProfileLoading
private let validator: ProfileValidating
private let analytics: AnalyticsTracking
}
Each collaborator is independently testable and independently small. The ViewModel becomes a thin orchestrator wiring these together for the view, rather than the place where all the logic itself lives.
2. Push business/domain logic into a separate layer entirely
A common source of ViewModel bloat is business rules that don’t actually belong to “how does this screen behave” — validation rules, pricing calculations, eligibility checks. Move these into plain domain/service types the ViewModel merely calls:
struct DiscountCalculator {
func applicableDiscount(for cart: Cart, user: User) -> Discount { ... }
}
final class CheckoutViewModel {
private let discountCalculator: DiscountCalculator
}
This is effectively borrowing Clean Architecture’s use-case/interactor idea without full ceremony.
3. Extract navigation entirely — Coordinators (or a router/path)
Navigation decisions are a very common ViewModel bloat source (“if this succeeds, show screen B; if that fails, show an alert; if this condition, deep-link elsewhere”). Pulling this into a Coordinator, or in SwiftUI a centralized NavigationPath/router object, means the ViewModel exposes intent (“user wants to check out”) rather than making navigation decisions itself.
4. Split large screens into genuinely separate ViewModels per section
If a screen has multiple logically independent sections (a profile header, a settings list, an activity feed all on one screen), resist the urge to have one ViewModel own all of it. Give each section its own ViewModel, and have the screen-level ViewModel (if one exists at all) just compose child view models:
struct ProfileScreen: View {
@State private var header = ProfileHeaderViewModel()
@State private var settings = SettingsSectionViewModel()
@State private var activity = ActivityFeedViewModel()
}
5. Keep formatting/presentation logic out via separate formatters, not inline in the ViewModel
A frequent source of quiet bloat: date formatting, currency formatting, pluralization logic scattered across many computed properties in the ViewModel. Extract these into reusable formatter types or extensions:
extension Date {
var displayString: String { /* shared formatting logic */ }
}
Small individually, but this is exactly the kind of thing that accumulates into hundreds of lines of “just formatting” if left inline, and it’s trivially extractable since it has no dependency on the ViewModel’s state.
6. Use protocols to keep the ViewModel’s public surface honest
protocol ProfileViewModeling: ObservableObject {
var displayName: String { get }
func onEditTapped()
}
This is a lightweight guardrail — if you’re tempted to add a method that has nothing to do with presenting this screen, the protocol boundary makes that addition visibly out of place.
7. Avoid the “ViewModel owns networking and parsing directly” trap
Push this into a Repository/Service layer with a narrow interface:
protocol ProfileRepository {
func fetchProfile() async throws -> Profile
}
final class ProfileViewModel {
private let repository: ProfileRepository
}
This also directly improves testability — you inject a mock rather than needing to fake network responses.
8. Watch for “just one more @Published” creep, and revisit rather than accumulate
ViewModels rarely become massive in one commit — they grow one property, one method at a time, each individually reasonable. The practical discipline is periodic re-evaluation: when a ViewModel crosses some rough size/complexity threshold — a number you and your team agree on, or just “I can no longer hold this file’s behavior in my head” — that’s the trigger to stop and actively re-split it, rather than waiting for it to become unmanageable enough to force a larger rewrite later.
The underlying principle
A ViewModel’s job is specifically “prepare and expose state for this view, and translate user intent into calls to other layers.” Anything that isn’t that — business rules, navigation decisions, networking/parsing, formatting logic, cross-screen concerns — has a more natural home elsewhere, and the discipline is noticing when a ViewModel has quietly picked up one of those other jobs and extracting it before it compounds.
One-liner if pressed: “Massive ViewModels come from the same root cause as Massive View Controllers — a lack of a place to put things — so the fix is the same discipline applied one level deeper: push business logic into domain/service types, navigation into a Coordinator or router, networking into a repository, and formatting into dedicated formatters, so the ViewModel’s job stays narrowly ‘expose state and translate intent’ rather than becoming the dumping ground for everything the screen touches.”