Migrating from MVP to MVVM + Clean Architecture in iOS

A practical guide based on migrating a large-scale delivery app with 10M+ downloads, covering the why, the how, and the lessons learned along the way.

Why We Needed to Migrate

When you're working on an app that has grown over several years to serve millions of users, architectural debt becomes impossible to ignore. Our codebase had started with MVP (Model-View-Presenter), which served us well in the early days. But as the team grew to 15+ engineers and features became more complex, we started hitting walls.

The problems were clear:

  • Massive Presenters - Some presenters had grown to thousands of lines, handling everything from business logic to navigation.
  • Tight Coupling - Views were directly dependent on concrete implementations, making unit testing nearly impossible.
  • Merge Conflicts - Multiple engineers working on the same module meant constant conflicts in monolithic files.
  • Slow Onboarding - New team members struggled to understand where logic lived and how data flowed through the app.

The Target Architecture

We chose MVVM combined with Clean Architecture principles and Dependency Injection. Here's why this combination works well for large-scale iOS apps:

Layer Separation

Clean Architecture gives us clear boundaries between layers. Each layer has a single responsibility and communicates through well-defined protocols.

// Domain Layer - Pure business logic, no framework dependencies
protocol FetchOrdersUseCase {
    func execute(userId: String) async throws -> [Order]
}

// Data Layer - Repository implementation
final class OrderRepositoryImpl: OrderRepository {
    private let remoteDataSource: OrderRemoteDataSource
    private let localDataSource: OrderLocalDataSource

    func getOrders(userId: String) async throws -> [Order] {
        do {
            let orders = try await remoteDataSource.fetchOrders(userId: userId)
            try await localDataSource.cache(orders)
            return orders
        } catch {
            return try await localDataSource.getCachedOrders(userId: userId)
        }
    }
}

// Presentation Layer - ViewModel
final class OrderListViewModel: ObservableObject {
    @Published var orders: [OrderViewData] = []
    @Published var isLoading = false
    @Published var error: String?

    private let fetchOrdersUseCase: FetchOrdersUseCase

    func loadOrders() async {
        isLoading = true
        do {
            let result = try await fetchOrdersUseCase.execute(userId: currentUserId)
            orders = result.map { OrderViewData(from: $0) }
        } catch {
            self.error = error.localizedDescription
        }
        isLoading = false
    }
}

Dependency Injection

DI was the key enabler for the entire migration. By injecting dependencies through protocols, we could swap out implementations without touching the consuming code. This made it possible to migrate module by module rather than doing a risky big-bang rewrite.

// Protocol-based dependency definition
protocol OrderRepository {
    func getOrders(userId: String) async throws -> [Order]
    func getOrderDetail(id: String) async throws -> OrderDetail
}

// DI Container registration
extension DependencyContainer {
    func registerOrderDependencies() {
        register(OrderRepository.self) {
            OrderRepositoryImpl(
                remoteDataSource: self.resolve(),
                localDataSource: self.resolve()
            )
        }
        register(FetchOrdersUseCase.self) {
            FetchOrdersUseCaseImpl(repository: self.resolve())
        }
    }
}

The Migration Strategy

Rewriting everything at once was not an option. We had 100K+ daily active users and couldn't afford downtime or regressions. Here's the approach that worked for us:

1. Start with New Features

Every new feature was built using the new architecture from day one. This gave the team hands-on practice with the patterns without the risk of breaking existing functionality.

2. Extract Shared Logic First

We identified common patterns across modules - networking, caching, error handling - and built clean abstractions for these first. This created a foundation that the rest of the migration could build on.

3. Migrate Module by Module

We prioritized modules based on two factors: how frequently they changed (high-churn modules benefited most from better architecture) and how critical they were to the user experience.

4. Write Tests as You Go

Each migrated module came with unit tests for the ViewModel and Use Case layers. Since dependencies were injected through protocols, writing mock implementations for testing was straightforward.

// Testing becomes simple with protocol-based DI
final class MockOrderRepository: OrderRepository {
    var stubbedOrders: [Order] = []
    var shouldThrowError = false

    func getOrders(userId: String) async throws -> [Order] {
        if shouldThrowError { throw TestError.mock }
        return stubbedOrders
    }
}

func testLoadOrdersSuccess() async {
    let mockRepo = MockOrderRepository()
    mockRepo.stubbedOrders = [Order.sample]
    let viewModel = OrderListViewModel(
        fetchOrdersUseCase: FetchOrdersUseCaseImpl(repository: mockRepo)
    )

    await viewModel.loadOrders()

    XCTAssertEqual(viewModel.orders.count, 1)
    XCTAssertFalse(viewModel.isLoading)
    XCTAssertNil(viewModel.error)
}

Results

After completing the migration across all major modules, the impact was measurable:

  • PR turnaround time dropped by 40% - Smaller, focused files meant faster reviews and fewer conflicts.
  • Test coverage increased significantly - Protocol-based DI made it straightforward to write meaningful unit tests.
  • New feature development sped up - Clear patterns meant engineers could scaffold new features quickly and consistently.
  • Onboarding time reduced - New team members could understand the codebase faster thanks to consistent, well-defined layers.

Key Takeaways

The best architecture migration is the one your team can execute incrementally without disrupting your users.

  1. Don't rewrite everything at once. Incremental migration reduces risk and lets you learn as you go.
  2. Protocols are your best friend. They enable DI, testability, and the ability to swap implementations seamlessly.
  3. Invest in the DI container early. A solid DI setup makes everything else easier.
  4. Let the team practice on new features first. Building muscle memory with the new patterns before touching legacy code avoids costly mistakes.
  5. Measure the impact. Track metrics like PR review time, crash rates, and test coverage to validate that the migration is delivering value.

Architecture decisions should always be driven by the real problems your team faces, not by trends. For us, MVVM + Clean Architecture with DI solved our specific scaling challenges and set us up for the next phase of growth.

Open Source: CleanArchGen

To make it easier to scaffold new modules following this architecture, I built CleanArchGen - a command-line tool that generates MVVM and VIPER modules with all the boilerplate: ViewModel, View, UseCase, Repository, DataSource, Coordinator, DI container, and tests. One command, consistent structure across your entire codebase.

Back to all posts