DEV Community

Cover image for MVVM vs VIP, which one is the best?
Suprie
Suprie

Posted on

MVVM vs VIP, which one is the best?

The short answer is neither.

There is no silver bullet; no single architecture can fit all codebases. And code, like requirements, is never fixed. It evolves alongside the codebase. What should not drift accidentally are boundaries.

For a greenfield project, I'd start with MVVM. Not because MVVM is inherently better than VIP, VIPER, or any other architecture, but because it's simple enough to start with, many developers are familiar with it, it works naturally with SwiftUI's state-driven model, and if done well, keeps the ViewModel focused on presentation state.

The mistake was thinking that an architecture's name will protect the codebase for you. It won't. A ViewModel can still become a dumping ground for network, persistence, and business rules. If infrastructure details leak into the Interactor or ViewModel, or dependencies are not hidden behind appropriate adapters, the code becomes harder to isolate, replace, and test.

Here's what a bad Interactor looks like:

class LoginInteractor: LoginBusinessLogic {
    let displayLogic: LoginDisplayLogic

    func doLogin(username: String, password: String) {
        Task {
            do {
                guard let url = URL(string: "https://api.example.com/login") else { return }
                var request = URLRequest(url: url)
                request.httpMethod = "POST"
                let (data, _) = try await URLSession.shared.data(for: request)
                let _ = try JSONDecoder().decode(LoginResult.self, from: data)
                displayLogic.showLoginSuccess()
            } catch {
                displayLogic.showLoginFailed(message: error.localizedDescription)
            }
        }
    }
}

struct LoginInteractorTest {

    @Test func testDoLogin() async throws {
        let displayLogic = FakeLoginDisplayLogic()
        let interactor = LoginInteractor(displayLogic: displayLogic)

        interactor.doLogin(username: "abc", password: "abc")

        // doLogin fires a Task internally — there's no way to await it from here.
        // We can't inject a fake result, so this test either:
        //   1. Hits the real endpoint with real credentials, or
        //   2. Races the network call and asserts before it even completes.
        try await Task.sleep(for: .seconds(2)) // guessing how long the network needs
        #expect(displayLogic.isLoggedIn == true) // flaky: fails if the network is slow
    }

}
Enter fullscreen mode Exit fullscreen mode

Compare that to an Interactor that depends on an abstraction instead:

protocol LoginWorker {
    func doLogin(username: String, password: String) async throws -> LoginResult
}

class LoginInteractor: LoginBusinessLogic {
    let displayLogic: LoginDisplayLogic
    let worker: LoginWorker
    func doLogin(username: String, password: String) {
        Task {
            do {
                let _ = try await worker.doLogin(username: username, password: password)
                displayLogic.showLoginSuccess()
            } catch {
                displayLogic.showLoginFailed(message: error.localizedDescription )
            }
        }
    }
}

struct LoginInteractorTest {

    @Test func testDoLogin() async throws {
        let displayLogic = FakeLoginDisplayLogic()
        // Now you can just mock the worker, make it throw any error like succeed,
        // ,failed, or timeout without need to wait for estimated time api call succeed
        // because it never happened
        let worker = FakeLoginWorker()
        let interactor = LoginInteractor(displayLogic: displayLogic, worker: worker)
        interactor.doLogin(username: "abc", password: "abc")

        #expect(displayLogic.isLoggedIn == true)
    }
}
Enter fullscreen mode Exit fullscreen mode

The architecture name matters less than whether the boundaries are actually respected.

The goal was never to reach a specific architecture, the goal is always to make the code understandable as complexity grows.

Dependency direction still matters:

Presentation -> Application / Use Case -> Domain <- Data Infrastructure

That direction defines the boundaries. UI should not know how data is fetched. Business rules should not care whether the data comes from an API, cache, or local DB. Infrastructure should not dictate how the application works.

Those boundaries are more important than whether a type is called ViewModel, Presenter, Interactor, or UseCase. If developers repeatedly struggle to answer questions like:

  • Where does this behavior belong?
  • Why does changing this feature require touching unrelated code?
  • Why do I need to understand five layers to change one screen?
  • What else breaks if I change this?

Then the current architecture may no longer be doing its job. That's a signal to refactor — not because of hype, but because the existing structure is no longer helping the team reason about change.

For an existing codebase, I'd optimize for incremental boundary improvements rather than introducing a new architecture everywhere. Create seams and the Strangler Fig pattern. The goal is understanding the system, not adopting any particular architecture pattern. Refactoring an existing codebase without understanding it first is like entering the battlefield without joining the briefing. You might know how to fight, but you don't yet know the objective, the terrain, the constraints, or why the current position exists.

Ugly code sometimes contains important business knowledge, or a workaround from a production incident years ago. Duplication may be accidental, or it may represent behavior that diverged for a reason. Before replacing the architecture, understand the system.

For a legacy codebase, the process is closer to:

Understand -> Test -> Find seam -> Replace -> Reassess

rather than

Choose architecture -> Rewrite

Testability is another important part of this. Architectures like MVVM and VIP often trade some delivery speed for clearer responsibilities, predictability, and easier testing. But testability only matters if you actually make use of it. Architecture can tell you where responsibilities belong. Tests tell you what the system is expected to do. That distinction becomes critical over time. Three months after a feature ships, someone will start wondering: "Is this intentional, or a bug?" A good dependency graph can't answer that, but more often than not, a good test can.

Consistency helps make reviews, debugging, onboarding, and refactoring easier. But it's not the goal by itself. The goal is a system the team can understand and change safely.

The best architecture is not the one with the most rules. It's the lightest structure that preserves your system's boundaries, and still lets the team change the code with confidence.

Top comments (0)