DEV Community

Vinayak G Hejib
Vinayak G Hejib

Posted on • Edited on

Modernizing a Legacy Modular iOS Codebase

SwiftUI Migration, Scalable Architecture & Seamless Testability

"You don’t rewrite a legacy codebase. You rearchitect it, one module at a time."

In this guide, we break down how we can transform a large-scale, UIKit-based modular monolith into a SwiftUI-driven, testable, maintainable codebase with a clean architecture. The transformation touched every part of the stack: from UI to API, from navigation to build systems.


The Problem

Lets say, over time, your iOS app evolved into a monolithic modular architecture:

  • Organized by feature folders (e.g., Home, Profile, Login).
  • Common modules like Networking, Models, Storage.
  • Heavy reliance on UIKit, Storyboards, and XIBs.

Pain Points:

  • Bloated ViewControllers: Handling UI, navigation, API, caching all at once.
  • Tight Coupling: Feature modules often depended on each other directly.
  • Unscalable Navigation: Navigation logic spread across multiple layers.
  • Poor Testability: Difficult to write unit or integration tests due to side effects.
  • Inconsistent Architecture: Teams used different patterns (MVC, MVVM, Singleton-based flows).
  • Slow Build Times: Everything compiled in one target; CI times increased.

We need a modern, scalable, testable, and maintainable solution.


🛠 Step 1: Choosing the Right Architecture

After reviewing several architecture styles (VIPER, RIBs, Clean, TCA), I would prefer:

✅ MVVM + Coordinators + Dependency Injection

Why?

  • Fits SwiftUI's declarative nature
  • ViewModels hold business logic and state
  • Coordinators isolate navigation, making it reusable and testable
  • Dependency Injection enables test mocking and clear boundaries

Swift Package Manager (SPM) for Modularization

Use SPM to break our app into independently buildable, testable components.


📂 Step 2: Modularization Strategy

Inspired by vbat.dev, we defined modules by feature and responsibility.

Types of Modules:

Layer Modules Examples
Feature HomeFeature, SearchFeature, CartFeature Business logic + UI
Core CoreNetworking, CoreModels, CoreStorage Reusable low-level components
UI SharedUI, DesignSystem Custom buttons, theme, reusable views
Infra Analytics, CrashReporting Services, APIs, Gateways

Rule: Feature modules should only depend on Core or Infra. Never on each other.

Structure Example:

/Packages
  /Features
    /HomeFeature
  /Core
    /CoreNetworking
    /CoreModels
  /UI
    /SharedUI
Enter fullscreen mode Exit fullscreen mode

Step 3: Refactor the Core First

Before migrating UI or adding SwiftUI, we started with refactoring our core modules, especially CoreNetworking and CoreModels.

Why Start with Core?

  • Core defines how features interact with data and services
  • Consistent APIs and dependency rules enable scalable adoption
  • Core is reused across all features — enforcing good practices here radiates out
  • Encourages a decoupled, protocol-first design supporting testability and flexibility

CoreNetworking Redesign Goals:

  • Define API requests using protocols (APIRequest, APIClientProtocol)
  • Use Result-based decoding pipelines
  • Make all requests async and testable
  • Provide mockable interfaces for all networking services

Example:

protocol APIRequest {
    associatedtype Response: Decodable
    var path: String { get }
    var method: HTTPMethod { get }
}

protocol APIClientProtocol {
    func send<T: APIRequest>(_ request: T) async throws -> T.Response
}

final class APIClient: APIClientProtocol {
    func send<T>(_ request: T) async throws -> T.Response where T: APIRequest {
        // Build URLRequest, perform, decode
    }
}
Enter fullscreen mode Exit fullscreen mode

Result:

  • ViewModels across features now depend only on APIClientProtocol
  • Services (e.g., HomeService) use these APIs internally and are easy to stub in tests
  • Unified patterns help enforce clean architecture across teams

📈 Enhancing CoreModels and CoreStorage

DTO vs. Domain Model

To maintain separation between networking layers and domain logic, we used the DTO → DomainModel transformation pattern:

This allows our ViewModels and business logic to work with clean, decoupled domain objects while networking remains isolated.

Beyond Networking, we standardized CoreModels and CoreStorage:

  • Models conform to Codable and are shared across modules.
  • Use of DomainModel vs DTO separation helped decouple layers.
  • Abstracted UserDefaults, Keychain, and FileStorage under unified protocols:
protocol KeyValueStoring {
    func get<T: Decodable>(_ key: String) -> T?
    func set<T: Encodable>(_ value: T, for key: String)
}
Enter fullscreen mode Exit fullscreen mode
  • Concrete implementations (e.g., UserDefaultsStore, MockStorage) live inside Core or TestTargets
  • This made it easy to mock persistence during unit testing and eliminate side effects.

Tooling: Enforcing Architecture Contracts

To scale this approach across teams, we adopted tools to enforce boundaries and consistency:

SwiftLint

  • Custom rules to restrict module imports
  • Enforce naming conventions (*ViewModel.swift, *Coordinator.swift, etc.)

Sourcery

  • Auto-generate mocks for protocols using // sourcery: AutoMockable
  • Prevents writing tedious boilerplate for DI testing

Tuist (or Bazel)

  • Automate SPM graph generation
  • Validate dependency rules
  • Scales well for large modular workspaces

Step 4: Refactoring a Real Module - HomeFeature

Feature Module Dependency Graph

This diagram illustrates how each feature module (e.g., Home, Profile, Search) depends only on shared Core modules and not on each other, enforcing clean separation and reusability.

Original State:

  • 600+ LOC in HomeViewController
  • UICollectionView + network fetches in the controller
  • Logic for loading, caching, refresh, navigation all tightly coupled
  • Hard to test; lots of UI-state bugs

Goals:

  • Move UI to SwiftUI
  • Extract logic to ViewModel
  • Use Coordinator for navigation
  • Inject dependencies (no singletons)

🔁 Step 5: Architecture in Action

HomeViewModel.swift

@MainActor
final class HomeViewModel: ObservableObject {
    @Published var items: [HomeItem] = []
    @Published var isLoading = false
    @Published var error: Error?

    private let service: HomeServiceProtocol

    init(service: HomeServiceProtocol) {
        self.service = service
    }

    func fetchItems() async {
        isLoading = true
        do {
            items = try await service.fetchItems()
        } catch {
            self.error = error
        }
        isLoading = false
    }
}
Enter fullscreen mode Exit fullscreen mode

HomeView.swift

struct HomeView: View {
    @ObservedObject var viewModel: HomeViewModel

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView()
                } else if let error = viewModel.error {
                    Text("Error: \(error.localizedDescription)")
                } else {
                    List(viewModel.items) { item in
                        HomeRow(item: item)
                    }
                }
            }
            .navigationTitle("Home")
        }
        .onAppear {
            Task { await viewModel.fetchItems() }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

HomeCoordinator.swift

final class HomeCoordinator: ObservableObject {
    @Published var path = NavigationPath()

    func start() -> some View {
        let vm = HomeViewModel(service: HomeService())
        return HomeView(viewModel: vm).environmentObject(self)
    }

    func goToDetail(for item: HomeItem) {
        path.append(item)
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Improving Testability

By adopting dependency injection, protocol-first design, and clear ViewModel boundaries, our modules became much more testable.

Unit Test Example

final class HomeViewModelTests: XCTestCase {
    func testFetchItemsSuccess() async {
        let mockService = MockHomeService()
        mockService.stubbedItems = [.mock(), .mock()]

        let vm = HomeViewModel(service: mockService)
        await vm.fetchItems()

        XCTAssertEqual(vm.items.count, 2)
        XCTAssertNil(vm.error)
    }
}
Enter fullscreen mode Exit fullscreen mode

SwiftUI Testing

  • Use .accessibilityIdentifier() to enable UI automation
  • Leverage ViewInspector for unit testing SwiftUI views
  • Snapshot testing using iOSSnapshotTestCase or SwiftSnapshotTesting

Step 7: CI, Build Times & Developer Experience

Update CI pipelines and local builds to benefit from modularization:

  • SPM made incremental builds faster (~40% improvement)
  • CI workflows ran unit tests per package
  • Pre-commit linting and type checks prevented regressions

Tooling Summary

Tool Use
SPM Modularized features and core logic
SwiftLint Enforced import boundaries and naming rules

Add on:

Centralized Feature Initialization via a Builder Pattern

In a modular SwiftUI codebase, it's essential to cleanly instantiate feature modules with their dependencies. Instead of creating each module inline or in scattered views, we can define a FeatureModuleBuilder that:

Step 1: Define Core Services

// CoreNetwork / CoreStorage
protocol APIClientProtocol {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}

protocol CacheManagerProtocol {
    func get(_ key: String) -> Data?
    func save(_ data: Data, for key: String)
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Define Feature Modules with Dependency Structs

// HomeFeature/HomeDependencies.swift
struct HomeDependencies {
    let apiClient: APIClientProtocol
    let cacheManager: CacheManagerProtocol
}

// HomeFeature/HomeView.swift
struct HomeView: View {
    @StateObject private var viewModel: HomeViewModel

    init(dependencies: HomeDependencies) {
        _viewModel = StateObject(wrappedValue: HomeViewModel(
            apiClient: dependencies.apiClient,
            cacheManager: dependencies.cacheManager
        ))
    }

    var body: some View {
        List(viewModel.items, id: \.id) {
            Text($0.title)
        }
        .task {
            await viewModel.loadItems()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Feature Module Builder

// MainApp/FeatureModuleBuilder.swift
struct FeatureModuleBuilder {
    private let apiClient: APIClientProtocol
    private let cacheManager: CacheManagerProtocol

    init(apiClient: APIClientProtocol, cacheManager: CacheManagerProtocol) {
        self.apiClient = apiClient
        self.cacheManager = cacheManager
    }

    func makeHomeView() -> some View {
        let deps = HomeDependencies(apiClient: apiClient, cacheManager: cacheManager)
        return HomeView(dependencies: deps)
    }

    // Add more feature modules here
    // func makeProfileView() -> some View { ... }
    // func makeSearchView() -> some View { ... }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: RootCoordinator Using the Builder

// MainApp/RootCoordinator.swift
struct RootCoordinator: View {
    private let builder: FeatureModuleBuilder

    init(builder: FeatureModuleBuilder) {
        self.builder = builder
    }

    var body: some View {
        builder.makeHomeView()
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Final Entry Point

// MainApp/MainApp.swift
@main
struct MainApp: App {
    var body: some Scene {
        let apiClient = DefaultAPIClient()
        let cacheManager = InMemoryCacheManager()

        let builder = FeatureModuleBuilder(
            apiClient: apiClient,
            cacheManager: cacheManager
        )

        WindowGroup {
            RootCoordinator(builder: builder)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits of the Builder Pattern

  • Centralized wiring of feature modules and dependencies
  • Easy to extend when adding new modules
  • Keeps SwiftUI views clean and declarative
  • Test-friendly structure — builder can inject mocks during testing

Lives in the main app target
Knows how to wire each feature with its required services
Keeps dependency composition clean and maintainable

🛤 What’s Next?

  • Expand SwiftUI adoption across remaining modules
  • Build cross-platform foundations (macOS, visionOS)
  • Define shared state management (e.g., Combine, ObservableDomain)
  • Explore performance gains with SwiftData + Swift Macros

🔗 References & Resources


Migrating to SwiftUI and modular architecture isn't just about code. It's about empowering teams to move faster, test better, and scale fearlessly.

Top comments (0)