iosNudge

How would you modularize an iOS app with Swift Package Manager?

HardArchitectureTooling 2 min read

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/CheckoutInterface

Rule: 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 public API. 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.