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:

  1. Put a protocol between each ViewModel and its dependencies.
  2. 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...