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
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
}
}
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
vsDTO
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)
}
- 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
}
}
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() }
}
}
}
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)
}
}
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)
}
}
SwiftUI Testing
- Use
.accessibilityIdentifier()
to enable UI automation - Leverage ViewInspector for unit testing SwiftUI views
- Snapshot testing using
iOSSnapshotTestCase
orSwiftSnapshotTesting
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)
}
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()
}
}
}
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 { ... }
}
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()
}
}
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)
}
}
}
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)