12.6 — Modularization with Swift Package Manager
Opening scenario
Your app’s clean build is 9 minutes. Incremental builds are 90 seconds because changing a single button color recompiles the universe. You add a new engineer; they wait 20 minutes for the first build. This is the universal symptom of a monolithic target — and SPM-driven modularization is the cure.
Context — modularization options
| Approach | When | Drawbacks |
|---|---|---|
| Single target | < 20k lines, 1–2 engineers | Doesn’t scale |
| Xcode targets/frameworks | Older codebases pre-SPM | Project file conflicts, harder to share |
| SPM packages (local) | 2026 default | Initial setup cost |
| Tuist / XcodeGen | Very large teams generating projects | Extra tooling layer |
| Bazel / Buck | Hundreds of engineers (Uber, Lyft, Airbnb) | Massive infra investment |
For 90 % of iOS teams in 2026, local SPM packages is the right tool.
The modularization rules
- One feature = one package, with one or more products (
Library). - Core packages (Networking, Persistence, DesignSystem) have no feature dependencies.
- Feature packages depend on Core, never on each other directly.
- Interfaces vs implementations: split when feature A needs to call into feature B without owning the build dependency.
The dependency graph is a DAG pointing toward Core. Cycles are a build error in SPM, which is exactly the discipline you want.
Concept → Why → How → Code
Concept: every feature lives in Modules/<Feature>/Package.swift. The app target links them all and runs the composition root.
Why: parallel compilation, granular cache invalidation, enforced boundaries, previewable in isolation.
How — a typical layout:
MyApp/
├── App/ ← Xcode project (App target only)
│ └── MyApp.xcodeproj
├── Modules/
│ ├── Core/
│ │ ├── DesignSystem/Package.swift
│ │ ├── Networking/Package.swift
│ │ ├── Persistence/Package.swift
│ │ └── Analytics/Package.swift
│ ├── Features/
│ │ ├── Onboarding/Package.swift
│ │ ├── Feed/Package.swift
│ │ ├── Profile/Package.swift
│ │ └── Settings/Package.swift
│ └── Interfaces/
│ └── FeedInterface/Package.swift ← protocols only
A feature package
// Modules/Features/Feed/Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Feed",
platforms: [.iOS(.v17)],
products: [
.library(name: "Feed", targets: ["Feed"]),
],
dependencies: [
.package(path: "../../Core/DesignSystem"),
.package(path: "../../Core/Networking"),
.package(path: "../../Core/Analytics"),
.package(path: "../../Interfaces/FeedInterface"),
],
targets: [
.target(
name: "Feed",
dependencies: [
"DesignSystem", "Networking", "Analytics", "FeedInterface"
],
path: "Sources/Feed"
),
.testTarget(
name: "FeedTests",
dependencies: ["Feed"],
path: "Tests/FeedTests"
),
]
)
Interface segregation
Onboarding may need to navigate to Feed.RootView without compiling the entire Feed module. Solution: FeedInterface package containing only the navigation protocol.
// Modules/Interfaces/FeedInterface/Sources/FeedInterface/FeedRoute.swift
import SwiftUI
public protocol FeedNavigator {
func makeRootView() -> AnyView
}
// Modules/Features/Feed → conforms
public struct LiveFeedNavigator: FeedNavigator {
public init() {}
public func makeRootView() -> AnyView { AnyView(FeedRootView()) }
}
// Onboarding only imports FeedInterface (cheap)
import FeedInterface
struct OnboardingDoneView: View {
let feed: FeedNavigator
var body: some View { feed.makeRootView() }
}
Now editing Feed doesn’t trigger an Onboarding rebuild — only the App target links both.
Binary targets
For closed-source SDKs (analytics vendors, video players):
.binaryTarget(
name: "Mixpanel",
url: "https://github.com/mixpanel/mixpanel-iphone/releases/download/v5.1.0/Mixpanel.xcframework.zip",
checksum: "abc123…"
)
Compute checksum: swift package compute-checksum Mixpanel.xcframework.zip.
Build-time wins (measured)
| Before (monolith) | After (12 modules) |
|---|---|
| Clean: 9 min | Clean: 5 min |
| Incremental (1 file in 1 feature): 90 s | Incremental: 8 s |
| SwiftUI Preview compile: 45 s | Preview: 6 s |
Numbers are from a real ~120k-line codebase; your mileage varies, but the order of magnitude improvement on incremental + preview is consistent.
In the wild
- Airbnb iOS broke from monolith to 300+ modules with Bazel; the SPM-only equivalent is feasible up to ~100 modules.
- Lyft iOS: hundreds of modules, custom tooling on top of SPM.
- Apple’s own Xcode templates (the SwiftUI App template’s new “App + Package” variant in Xcode 16) ship with a 2-module split as the suggested starting point.
Common misconceptions
- “Modularization slows builds.” Initial clean builds may be marginally slower; incremental builds dramatically faster. The incremental case is what you live in daily.
- “You should modularize on day one.” Premature modularization makes early refactoring painful. Extract modules when files exceed ~30k lines or builds exceed ~3 min.
- “Every feature needs an Interface module.” Only those that other features must call into. Most features need no interface.
- “Mixing local and remote SPM dependencies is unsafe.” It’s fine —
dependencies: [.package(path: "..."), .package(url: "...")]works. - “Xcode previews break with modular SPM.” They work better — previews compile only the target module.
Seasoned engineer’s take
Start with two modules: App (the runnable target) and AppCore (everything else). Split further when build pain or team coordination forces it. Never split modules for theoretical purity. Track build times in CI as a first-class metric — they’re the canary for unmanaged growth.
TIP: Use
swift package generate-documentation(DocC) per module. A modular app generates browsable per-module docs for free.
WARNING: Circular dependencies between modules are a compiler error in SPM — that’s a feature, not a bug. Resolve by extracting an Interface module, never by breaking the rule.
Interview corner
Junior: “Why would you split an iOS app into multiple Swift packages?” Faster incremental compilation, enforced boundaries between features, parallel team work without merge conflicts, and the ability to develop and test features in isolation with SwiftUI previews.
Mid: “How do you handle a feature that needs to navigate to another feature without a build dependency?”
Extract an Interface package containing the protocol (e.g. FeedNavigator). Both features import the Interface; only the App target links the concrete implementation and wires it up at composition root. No circular dependency, decoupled compile units.
Senior: “Walk me through how you’d modularize a 200k-line monolith iOS app.”
First I’d add metrics: instrument the build to track clean and incremental times per change pattern. Then I’d identify the lowest-coupling extractable pieces — DesignSystem, Analytics, Networking — and lift them to packages without changing their callers (the package import is the only diff). I’d measure build improvement after each. For features I’d start with the smallest leaf feature, modularize it, and use that as the team template. I’d avoid the “big bang” rewrite — every modularization is a separate PR with measurable wins. Around 30+ modules I’d start evaluating whether to introduce a project-generation tool (XcodeGen/Tuist) to keep the .xcodeproj manageable, or whether to fully eliminate the xcodeproj in favor of a pure SPM workspace.
Red-flag answer: “Modularize everything for cleanliness” — without acknowledging the cost of premature splits or the importance of measuring.
Lab preview
Lab 12.2 takes a single-target, 4-screen toy app and walks you through extracting DesignSystem and Networking packages, then the first feature module. By the end you’ll have the template for modularizing any codebase.