Avoiding Fat ViewModels in SwiftUI: A Practical MVVM Upgrade with Repository + Actor
MVVM is easy to adopt and hard to scale.
Avoiding Fat ViewModels in SwiftUI: A Practical MVVM Upgrade with Repository + Actor

MVVM is easy to adopt and hard to scale.
Most SwiftUI codebases start clean: a ViewModel with loading flags, a list of items, and one async call. A few sprints later, that same ViewModel is doing API calls, cache validation, local persistence, retries, and UI state updates. It still “works,” but now every change is risky.
This post shows a practical way to keep MVVM clean in real projects by separating responsibilities across:
- ViewModel for presentation state
- Repository for data orchestration policy
- DataSources for implementation details
- Actor for shared mutable async cache state
The Problem: The Omniscient ViewModel
A fat ViewModel usually contains:
- local reads/writes
- network calls
- cache expiry logic
- fallback rules
- UI transformations
The issue is not code size. The issue is mixed responsibilities.
When policy, infrastructure, and UI state live in one class:
- tests become painful
- race conditions become easier to introduce
- regressions increase when requirements change
A cleaner approach is to let the Repository orchestrate data flow. It becomes the single place that decides when cached data is trustworthy, when the app should revalidate against the API, and how to degrade gracefully if the network fails. That separation keeps the UI layer simple, makes failure handling predictable, and gives the application a clearer, more testable runtime path.
protocol TodoRepositoryProtocol {
func getTodos() async throws -> [TodoItem]
}
final class TodoRepository: TodoRepositoryProtocol {
private let remote: TodoRemoteDataSourceProtocol
private let local: TodoLocalDataSourceProtocol
private let cacheTracker: CacheValidityTracker
init(
remote: TodoRemoteDataSourceProtocol,
local: TodoLocalDataSourceProtocol,
cacheTracker: CacheValidityTracker
) {
self.remote = remote
self.local = local
self.cacheTracker = cacheTracker
}
func getTodos() async throws -> [TodoItem] {
let cachedTodos = local.loadTodos()
if await cacheTracker.isFresh(), !cachedTodos.isEmpty {
return cachedTodos
}
do {
let todos = try await remote.fetchTodos()
local.saveTodos(todos)
await cacheTracker.markFresh()
return todos
} catch {
guard !cachedTodos.isEmpty else { throw error }
return cachedTodos
}
}
}
actor CacheValidityTracker {
private var lastSuccessfulFetch: Date?
private let ttl: TimeInterval
init(ttl: TimeInterval) {
self.ttl = ttl
}
func isFresh(now: Date = .now) -> Bool {
guard let lastSuccessfulFetch else { return false }
return now.timeIntervalSince(lastSuccessfulFetch) < ttl
}
func markFresh(at date: Date = .now) {
lastSuccessfulFetch = date
}
}
Why Actor Here?
Actor is useful when:
- state is mutable
- state is shared across async calls
- correctness depends on safe sequencing
Cache freshness metadata is exactly that case.
Without actor, concurrent loads/retries/background triggers can read/update freshness state unsafely. With actor, access is serialised and predictable.
메타데이터
- post_id
- ccb4d980fe7f
- slug
- avoiding-fat-viewmodels-in-swiftui-a-practical-mvvm-upgrade-with-repository-actor-ccb4d980fe7f
- url
- https://medium.com/@singhurvashi2018/avoiding-fat-viewmodels-in-swiftui-a-practical-mvvm-upgrade-with-repository-actor-ccb4d980fe7f
- canonical_url
- https://medium.com/@singhurvashi2018/avoiding-fat-viewmodels-in-swiftui-a-practical-mvvm-upgrade-with-repository-actor-ccb4d980fe7f
- author_url
- https://medium.com/@singhurvashi2018
- status
- ok
- fetched_at
- 2026-07-10 10:46:47