If you started your architecture journey you've probably heard of MVVM (Model-View-ViewModel) and you might have even developed an entire app with it.
When I first added MVVM architecture to my app Polinu, my ViewModels contained all functionality. In a way it was great because we took everything out of the view, but as the dependencies added up the viewmodel got messy real quick. Maybe even more importantly, none of it was testable without spinning up the real thing.
The solution was to:
- Put a protocol between each ViewModel and its dependencies.
- Collapse all those protocol implementations down to a single
CoreInteractor.
This is one of the best ways to not only clean up your code, but to keep all functionality limited to one file and preventing unwanted changes.
Where I started: plain MVVM
Here's the shape of it. A ViewModel holds its managers directly:
@Observable
@MainActor
class HomeViewModel {
let dataManager: DataManager
let userManager: UserManager
var movies: [String] = []
init(dataManager: DataManager, userManager: UserManager) {
self.dataManager = dataManager
self.userManager = userManager
}
func loadData() async {
do {
let _ = try await userManager.getUser()
movies = try await dataManager.getMovies()
} catch {
// ...
}
}
}
Nothing wrong with this at three screens. The problems show up when the app grows, and there are two of them.
First, it's coupled to concrete types. To test HomeViewModel, I have to hand it a real DataManager, which wants a real DataService, which wants a network call. I can't isolate the ViewModel's logic from the world it talks to.
Second — and this is the subtler one — HomeViewModel can see everything. DataManager also has getProducts(). HomeView has no business fetching products, but nothing stops me (or future me, at 11pm) from typing dataManager.getProducts() right inside it. The ViewModel knows too much.
Use case: Polinu
Let's take a look at what one of my viewmodels looked like from a previous version of my app Polinu
import SwiftUI
@Observable
@MainActor
class StudyViewModel {
private let authManager: AuthManager
private let deckManager: DeckManager
private let flashcardManager: FlashcardManager
private let studySessionManager: StudySessionManager
private let analyticsManager: AnalyticsManager
private let userGoalsManager: UserGoalsManager
private let userProgressManager: UserProgressManager
private let pushManager: PushManager
private let logManager: LogManager
init(
authManager: AuthManager,
deckManager: DeckManager,
flashcardManager: FlashcardManager,
studySessionManager: StudySessionManager,
analyticsManager: AnalyticsManager,
userGoalsManager: UserGoalsManager,
userProgressManager: UserProgressManager,
pushManager: PushManager,
logManager: LogManager
) {
self.authManager = authManager
self.deckManager = deckManager
self.flashcardManager = flashcardManager
self.studySessionManager = studySessionManager
self.analyticsManager = analyticsManager
self.userGoalsManager = userGoalsManager
self.userProgressManager = userProgressManager
self.pushManager = pushManager
self.logManager = logManager
}
}
Did you count them? Thats a whopping nine dependencies! Believe it or not there were other viewmodels with up to 15 dependencies...
Move one: put a protocol in front
The fix for "knows too much" is to stop handing the ViewModel a manager and start handing it an interface describing exactly what this screen needs. Nothing more.
protocol HomeViewModelInteractor {
func getMovies() async throws -> [String]
func getUser() async throws -> String
}
That's the contract. Two methods. Then a concrete type that actually does the work by forwarding to the managers:
struct HomeInteractor: HomeViewModelInteractor {
let dataManager: DataManager
let userManager: UserManager
func getMovies() async throws -> [String] {
try await dataManager.getMovies()
}
func getUser() async throws -> String {
try await userManager.getUser()
}
}
And the ViewModel now depends on the protocol, not the managers:
@Observable
@MainActor
class HomeViewModel {
let interactor: HomeViewModelInteractor
var movies: [String] = []
init(interactor: HomeViewModelInteractor) {
self.interactor = interactor
}
func loadData() async {
do {
let _ = try await interactor.getUser()
movies = try await interactor.getMovies()
} catch {
// ...
}
}
}
Now the ViewModel's entire knowledge of the outside world is now those two methods. getProducts() doesn't exist as far as HomeViewModel is concerned — it's not on the protocol, so I literally can't call it. The interface is segregated down to what the screen actually uses.
And testing stops being a chore:
struct MockHomeInteractor: HomeViewModelInteractor {
func getMovies() async throws -> [String] { ["Test Movie"] }
func getUser() async throws -> String { "test_user" }
}
But that's not all...
So I did this everywhere. SettingsViewModel got a SettingsViewModelInteractor and a SettingsInteractor struct. The content screen got its own. Every screen: one protocol, one concrete struct.
Doing this for three ViewModels is fine. But for every new ViewModel afterwards it becomes a pain in the a@#.
So how do we keep the idea of abstracting to protocols while not making things too tedious?
Move two: one CoreInteractor, many protocols
The solution is to collapse every interactor struct into a single type that holds all the managers and exposes every method:
@MainActor
struct CoreInteractor {
let dataManager: DataManager
let userManager: UserManager
init(container: DependencyContainer) {
self.dataManager = container.resolve(DataManager.self)!
self.userManager = container.resolve(UserManager.self)!
}
func getProducts() async throws -> [Product] {
try await dataManager.getProducts()
}
func getMovies() async throws -> [String] {
try await dataManager.getMovies()
}
func getUser() async throws -> String {
try await userManager.getUser()
}
}
Then each ViewModel's protocol is satisfied by CoreInteractor with a one-line, empty extension:
protocol HomeViewModelInteractor {
func getMovies() async throws -> [String]
func getUser() async throws -> String
}
extension CoreInteractor: HomeViewModelInteractor { }
protocol SettingsViewModelInteractor {
func getUser() async throws -> String
func getMovies() async throws -> [String]
}
extension CoreInteractor: SettingsViewModelInteractor { }
The extensions are empty because CoreInteractor already has every method the protocols ask for. Conformance is free.
The part that matters
Two things to notice, because they're the whole point.
The ViewModel didn't change. HomeViewModel still depends on HomeViewModelInteractor. Its code is byte-for-byte identical to move one. From the ViewModel's seat, nothing happened. All I changed was who conforms to the protocol — from a bespoke HomeInteractor struct to the shared CoreInteractor. The protocol is the stable contract; the conformance is an implementation detail I'm free to refactor underneath it. That's the seam working exactly as intended.
Interface segregation still holds. This is the one people get wrong. Yes, CoreInteractor is wide — it exposes getProducts(), getMovies(), and getUser(). But HomeViewModel is typed against HomeViewModelInteractor, which only declares two of them. So inside HomeViewModel I still can't reach getProducts(). The concrete type is one big object; each protocol is a narrow window onto it. Every ViewModel sees only its slice, even though they're all backed by the same instance.
Wiring it up is the same instance everywhere:
let interactor = CoreInteractor(container: container)
let homeVM = HomeViewModel(interactor: interactor)
let settingsVM = SettingsViewModel(interactor: interactor)
Same CoreInteractor, each ViewModel handed a different view of it.
The trade-off
CoreInteractor grows. It becomes the one type that knows about every manager in the app, and it gets longer with each feature.
What I got in return: ViewModels coupled to nothing but a two-method protocol, mockable in a single struct, and a single place to add a dependency. For a codebase the size Polinu turned into it was totally worth it.
The next step from here is pulling routing out of the ViewModel too — which is where the CoreInteractor pattern gets a sibling, CoreRouter, and you start crossing into VIPER. But that's its own post...