How we migrated a large, high-load iOS app from Xcode Frameworks and CocoaPods to Swift Package Manager — without freezing feature development.
In August 2024, the CocoaPods project entered maintenance mode. Then in November 2024 it was announced that the trunk will become read-only on December 2, 2026. After that, publishing new pods or updating existing ones will no longer be possible.
That announcement became the final trigger for our migration to Swift Package Manager.
But honestly, we already had enough reasons to leave long before that.
The starting point
Our starting point was a fairly large and mature iOS application: the Manychat app with about 220k MAU.
By the time the migration started, the codebase was already modular:
- ~20 external dependencies managed through CocoaPods
- ~20 internal dependencies implemented as Xcode Frameworks
Ironically, this very modularization through Xcode Frameworks turned out to be the root of many problems.
Internal modules were implemented as separate targets in .xcodeproj, each compiled into a dynamic.framework.
Each dynamic framework is its own bundle, containing duplicated Swift metadata and adding hundreds of lines to the already monstrous project.pbxproj.
This dynamic linking came with several unpleasant side effects:
1. Bloated app size
107 MB for a mobile app is… not exactly elegant.
2. Painful configuration
Every framework required manual configuration: build settings, build phases, code signing, target configuration
Multiply that by dozens of modules and you get a configuration hell.
3. Endless conflicts in project.pbxproj
Whenever merge conflicts appeared, developers had to manually resolve them inside the gigantic and barely readable project.pbxproj file. A special kind of suffering.
And finally, maintaining two dependency systems at once (CocoaPods for external libraries and Xcode Frameworks for internal modules) was becoming increasingly painful.
Swift Package Manager promised a much cleaner future: a single, native dependency system fully integrated with the Apple ecosystem.
So we decided it was time.
Migration strategy
We split the migration into three phases:
- Move external dependencies from CocoaPods to SPM.
- Migrate internal frameworks from Xcode Frameworks to SPM.
- Refine the architecture afterward.
Phase 1: External dependencies — leaving CocoaPods
We migrated dependencies one by one, starting with libraries that already supported SPM.
A typical migration looked like this:
- Add the package in Xcode: File → Add Package Dependencies .
- Provide the repository URL and choose the exact version.
- Select the package products and attach them to the appropriate target.
- Remove the dependency from the Podfile.
- Verify imports still work.
- Run tests.
Some libraries didn’t support SPM yet. Most of them were forks hosted in Manychat repositories — such as centrifuge-ios (WebSocket client) or DiffTableDirector (table diffing library). For those, we had to add SPM support ourselves : write Package.swift, publish tags and releases.
One dependency required special treatment: our Kotlin Multiplatform analytics library (KMM). To integrate it via SPM we first had to add SPM support on the KMM side. That meant building an XCFramework inside the KMM project.
This phase of migration happened incrementally and took roughly 3 months. At the end of this phase we achieved native Xcode integration: SPM dependencies were connected directly through Xcode (XCRemoteSwiftPackageReference in .xcodeproj) without additional wrappers.
At this point we dropped storyboards entirely. We’d been using our own fork of SwinjectStoryboard to support them, and we were done with both.
Phase 2: Migrating internal frameworks to SPM packages
This was the hardest part.
After external dependencies moved to SPM, we ran into a fundamental problem: Xcode Frameworks cannot properly consume transitive dependencies from SPM packages.
Here’s a concrete example:
Logically, Services should receive Alamofire transitively through Networking. In reality, Xcode does not propagate SPM dependencies through chains of dynamic frameworks. Instead, it throws linker errors: undefined symbols.
Why? Because dynamic frameworks and SPM packages live in completely different dependency resolution worlds. This limitation of Xcode’s build system made the migration even more complicated.
We saw two ways forward.
Option 1: declare all dependencies explicitly and migrate frameworks gradually. Then every Xcode Framework that needs a transitive SPM dependency declares it as a direct one which means duplicating dependencies.
Before migration:
After migration:
This approach would allow gradual migration, and reduce the risk of errors at each step. But the downside was obvious: it would require duplicating dependencies across all targets. The dependency graph would become a mess — real dependencies tangled with workarounds, impossible to tell apart. And every time a new SPM package is added, all dependent frameworks would need updating.
In other words: a temporary solution that would likely become permanent. We didn’t like that.
Option 2: migrate everything in one massive PR. Convert all Xcode Frameworks into local SPM packages at once.
The upside was tempting: a clean dependency graph from day one, no duplicated dependencies, and proper transitive resolution.
The tradeoff was equally obvious: a huge pull request touching the entire codebase while active development continued. High risk. Lots of potential conflicts.
After a week of debating and experimenting, we chose Option 2. Better one painful surgery than months of living with architectural band-aids.
The big migration
The actual migration looked like this.
- We created a single Package.swift describing all 25 modules — about 515 lines. This was intentional: maintaining one manifest is easier than managing dozens.
Example:
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "Modules",
platforms: [.iOS(.v17)],
products: LocalModule.allCases.map { $0.product },
dependencies: ExternalPackage.packages,
targets: LocalModule.allCases.map { $0.target }
)
Deleted all framework targets from project.pbxproj.
Added Modules as a local SPM package (XCLocalSwiftPackageReference).
Attached products to the main app target.
Connected packages to test targets.
Ran all tests and fixed compilation errors.
Merged it into dev.
The whole migration took four very intense days.
New dependency architecture
During the migration we also cleaned up the architecture. Some improvements became possible thanks to SPM; others we implemented simply because the migration gave us a good opportunity.
- We reorganized the existing frameworks into two types of SPM modules: Core and Feature. Core for shared application components and Feature for individual features.
Modules/
├── Core/ ← Infrastructure and shared components (20 modules)
│ ├── Core/ — Foundation: TCA, Swinject, base extensions
│ ├── Models/ — Domain models
│ └── ...
│
└── Feature/ ← Product features (5 modules)
├── Automations/
└── ...
We introduced helpers for dependency declaration and validation:
Shared dependencies for feature modules — encapsulation of shared dependencies for feature modules:
private func featureDependencies() -> [Dependency] {
[
local(.core),
...
external(.swinject),
external(.tca),
]
}
Features then add only their own specific dependencies on top:
case .automations:
featureDependencies()
...
external(.kingfisher)
DependencyBuilder — so each module explicitly declares both its local and external dependencies:
@DependencyBuilder
private var dependencies: [Dependency] {
switch self {
case .core:
external(.swinject)
...
case .models:
local(.core)
external(.dateTools)
...
case .networking:
local(.core)
local(.models)
...
// ... the rest modules
}
}
Cycle detection — a validation check that prevents feature modules from depending on each other:
private func validateNoFeatureToFeatureDependencies() {
let featureNames = Set(LocalModule.allCases.filter(\.isFeature).map(\.name))
for module in LocalModule.allCases where module.isFeature {
let featureDeps = module.target.dependencies.compactMap { dep -> String? in
switch dep {
case .byNameItem(name: let name, _):
return featureNames.contains(name) ? name : nil
default:
return nil
}
}
if !featureDeps.isEmpty {
preconditionFailure(
"Feature module '\(module.name)' must not depend on other feature modules: \(featureDeps.joined(separator: ", "))"
)
}
}
}
Results
The most important outcome: we are no longer dependent on CocoaPods and now rely entirely on the native Apple dependency system.
But the migration produced several additional benefits:
App size down 31%. From 106.6 MB to 73.9 MB. This was a side effect of converting Xcode Frameworks to SPM packages, not something we specifically optimized for.
Where the savings came from:
- Static linking. SPM packages link statically by default. LTO (Link-Time Optimization) can see the entire app at once instead of separate frameworks, and strips more aggressively.
- Deduplication of Swift metadata. Each dynamic framework previously carried its own copy of Swift type metadata. Static linking lets the compiler deduplicate them.
- Dead code stripping. The static linker removes unused code more effectively. Dynamic frameworks were always linked in full — even if the app used 10% of a .framework, the other 90% stayed in the binary.
- No bundle overhead. Each .framework is a directory with an Info.plist, headers, and a code signature. With 20+ frameworks, that adds up.
A clear dependency graph. No words needed.
This is before:
This is after:
App launch 30% faster. From 1.1s — 1.3s down to stable 0.84s. Before migration, dyld had to load and bind every dynamic framework at startup — visible on the pre-main phase. With static linking, there’s one binary to load.

App start time: 1.29s before migration (5.13.1) vs 0.84s after (6.8.0).

The drop is hard to miss: launch time fell from ~1.0–1.1s to 0.84s after the migration in late February.
Clean build time down 28%. From 156 seconds to 113 seconds. SPM modules build in parallel just like frameworks — but without the .framework bundle copy phase and per-framework code signing.
The full migration took about six months: preparation, the actual move, and post-migration cleanup. We’re currently in the refinement phase, continuing to thin out the main app target by moving remaining logic into existing modules or extracting new ones, and migrating tests into SPM.






Top comments (0)