How would you modularize an iOS app with Swift Package Manager?
TL;DR
Move code from one big app target into many small modules (Swift packages). Typical layers: Core (networking, design system), Features (one per screen or flow), and a thin App target that wires them together. Benefits: faster incremental builds, clear boundaries with access control, easier testing, and parallel team work.
Why modularize?
- Build time: change one feature and only that module and its dependents rebuild.
- Boundaries: code in another module can only use what is
public. No accidental coupling. - Testing: each module has its own fast test target.
- Ownership: teams own modules.
- Previews: small modules make SwiftUI previews faster and more stable.
A typical structure
App (Xcode project, thin)
├── Features/ProfileFeature
├── Features/CheckoutFeature
├── Core/Networking
├── Core/DesignSystem
├── Core/Models
└── Interfaces/CheckoutInterfaceRule: dependencies point down. Features depend on Core. Core never depends on a feature. Features do not depend on each other directly.
A local package
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Features",
platforms: [.iOS(.v17)],
products: [
.library(name: "ProfileFeature", targets: ["ProfileFeature"]),
],
dependencies: [
.package(path: "../Core"),
],
targets: [
.target(
name: "ProfileFeature",
dependencies: [
.product(name: "Networking", package: "Core"),
.product(name: "DesignSystem", package: "Core"),
],
resources: [.process("Resources")]
),
.testTarget(name: "ProfileFeatureTests", dependencies: ["ProfileFeature"]),
]
)Drag the package folder into the Xcode project and add the product to the app target.
Access control matters
public struct ProfileScreen: View {
public init(userID: String) { self.userID = userID }
let userID: String
public var body: some View { Text(userID) }
}internal(default): only inside the module.package(Swift 5.9): visible to other modules in the same package, not to the app.public: visible to anyone who imports the module.
Forgetting public init is the most common first error. Structs get a synthesized init that is only internal.
Feature-to-feature navigation: interface modules
Profile needs to open Checkout. A direct dependency would couple them. Use an interface module instead:
// CheckoutInterface (tiny, no implementation)
public protocol CheckoutBuilding {
@MainActor func makeCheckout(cartID: String) -> AnyView
}
// ProfileFeature depends on CheckoutInterface only.
// The App target injects the real CheckoutFeature implementation.This also keeps the build graph wide and shallow, which builds faster in parallel.
Resources
Resources in a package are in Bundle.module, not Bundle.main.
Image("avatar-placeholder", bundle: .module)
let url = Bundle.module.url(forResource: "config", withExtension: "json")Static vs dynamic
SPM libraries are linked statically by default. That is good for launch time. Too many dynamic frameworks slow down app launch. Xcode 15+ also supports mergeable libraries to get dynamic in debug and static-like in release.
Common problems
- Cyclic dependencies: SPM does not allow them. Extract shared code into a lower module.
- A "Common" module that grows forever: split it by purpose.
- Too many tiny modules: more build overhead and more
publicAPI. Split by feature, not by file.
Common follow-up questions
- SPM vs CocoaPods? SPM is built into Xcode and is the standard now. CocoaPods is in maintenance mode, and its trunk is set to become read-only.
- How do you share code with an app extension? Put it in a package module and link it to both targets.
- Does modularization always speed up builds? It speeds up incremental builds. Clean builds may be similar or slower if the graph is deep.
- How do you check the module graph? Look at the package dependencies in Xcode, or run
swift package show-dependencies.