Every iOS app starts as a single Xcode project. That works fine for a year or two, but as the team grows and features accumulate, the monolith starts to show strain: slow compile times, merge conflicts in the same files, and a growing sense that changing one screen might break something unrelated. Swift Package Manager offers a way out, but moving from monolith to modules is not a simple checkbox toggle. This guide is for teams who have decided to modularize and need a practical, step-by-step approach to adopting SPM without grinding development to a halt.
Why Modularize? The Pain Points That Drive the Switch
The decision to modularize usually follows a pattern of escalating friction. Build times are the most visible symptom. In a monolithic project, every change triggers a recompilation of everything that depends on that file. With dozens of targets and hundreds of source files, a single-line fix can mean a two-minute wait. Modularizing with SPM isolates code into separate packages, so changes inside a module only require recompiling that module and its dependents, not the entire app.
But build time is only the tip. Team coordination suffers when multiple developers need to work on the same target. Feature branches conflict because everyone touches the same AppDelegate or shared networking layer. Modules enforce clearer ownership: a team responsible for the checkout flow owns that package, and others interact through a defined public API. This reduces accidental coupling and makes code reviews more focused.
Another driver is code reuse across apps. Organizations with multiple iOS targets (a main app, an extension, a watch companion) often duplicate code or use fragile workspace references. SPM packages can be shared across projects via Git URLs or a local registry, providing a single source of truth. The same networking client, design system, or analytics wrapper can be versioned and updated independently.
Finally, testing becomes more targeted. In a monolith, unit tests often require setting up the entire app environment. With modules, you can test a package in isolation, mocking only its direct dependencies. This leads to faster test suites and more confidence in each module’s correctness.
That sounds fine until you start slicing. The catch is that modularization introduces its own challenges: dependency graph cycles, version conflicts, and the overhead of managing multiple repositories. The key is to proceed incrementally, not with a big bang rewrite.
Common Signs You’re Ready for SPM
If your team has at least three iOS developers and you spend more than 15% of your day waiting for builds, modularization is worth considering. Another signal is when you find yourself copying files between projects or maintaining a shared framework via submodules. If you’re already using CocoaPods or Carthage, the transition to SPM can be gradual—you can keep existing dependencies while migrating your own code.
Prerequisites: What You Need Before You Start Slicing
Before creating your first Package.swift, settle a few foundational decisions. First, choose a version of Xcode that supports SPM fully. Xcode 12 and later have built-in support for adding packages directly, but Xcode 14+ handles package resolution more reliably. If your team is still on an older Xcode, consider upgrading first—debugging SPM issues on outdated tools adds unnecessary frustration.
Second, establish a Git workflow that accommodates multiple packages. You have two main options: keep all packages in a single repository (monorepo) or use separate repositories for each package. A monorepo simplifies versioning and atomic commits across packages, but it requires tooling to manage package resolution within the repo. Separate repos give each package its own version history and allow teams to release independently, but they increase coordination overhead. For most teams starting out, a monorepo with a folder structure for packages is the smoothest path. You can always split later if needed.
Third, decide on a versioning strategy. SPM relies on Git tags for version resolution. Semantic versioning (semver) is the standard: MAJOR.MINOR.PATCH. Define what a breaking change means for your modules. A change that removes a public function is major; adding a new function is minor; fixing an internal bug is patch. Without clear conventions, you’ll end up with version conflicts that are hard to resolve.
Fourth, audit your existing codebase. Identify candidate modules by looking for boundaries that already exist: a networking layer, a design system, a user authentication flow. Use a dependency graph tool (like Xcode’s built-in target dependencies or a script that scans imports) to visualize how files depend on each other. This will reveal cycles that need breaking before you can extract a package.
Checklist Before First Package.swift
- Xcode version ≥14 (recommended)
- Version control with clear branching strategy
- Decision on monorepo vs. multi-repo
- Semver convention documented and agreed
- Dependency graph of current monolith
- CI system that can handle multiple packages
Core Workflow: Extracting Your First Module
Start small. Choose a module that has few dependencies on the rest of the app—a utility library (date formatting, logging) or a networking client that only depends on Foundation. The goal is to get a working package integrated into the main app without breaking the build.
Step 1: Create the package. In Xcode, go to File > New > Package. Choose a name that matches the module’s responsibility (e.g., NetworkingKit). Xcode generates a Package.swift file and a directory structure with Sources and Tests folders. Move the relevant source files into the package’s Sources directory. Adjust imports: any code that relied on internal types from other parts of the monolith now needs to depend on other packages or be refactored to remove that dependency.
Step 2: Define the package manifest. In Package.swift, specify the platforms your package supports, the products it exposes (library or executable), and its dependencies on other SPM packages. For the first module, keep dependencies minimal—ideally none beyond Foundation. Use swift-tools-version:5.7 or later to access modern features like targetPath and resources.
Step 3: Add the package to the main app. In your Xcode project, go to File > Add Packages. For a local package, use the “Add Local” button and select the package folder. Xcode will resolve the dependency and add it to the project navigator. Replace the original source files in the app target with imports from the new package. For example, replace #import "NetworkingClient.h" with import NetworkingKit in Swift files.
Step 4: Build and test. Run the app and verify that everything compiles. Run the package’s unit tests (Xcode shows them in the test navigator). If the build fails, check for missing dependencies or incorrect access levels. Remember that SPM enforces module boundaries: internal types are not visible outside the package. You may need to mark some classes or methods as public or use @testable import in tests.
Step 5: Repeat for other modules. Once the first package is stable, extract the next one. Gradually, the monolith shrinks and the app target becomes a thin composition layer that imports all packages and wires them together. Resist the urge to extract everything at once. Prioritize modules that change often or are used by multiple features.
Incremental Migration in Practice
One team I read about extracted their design system first: colors, typography, and reusable UI components. This gave immediate value because the design system was imported into multiple features and the main app. After that, they extracted the networking layer, then the authentication flow. Each extraction took about a week, with one developer dedicated to the migration while others continued feature work on a branch that still used the old files. They merged the package extraction branch only after the team had updated all references.
Tools and Setup: Making SPM Work in Your Environment
SPM integrates with Xcode, but the default workflow assumes all packages are on GitHub or a similar Git host. For teams that want to share packages internally without pushing to a public remote, a local package registry or a shared Git server (like GitLab or Bitbucket) is essential. You can also use a Package.swift with path: dependencies during development, but that doesn’t scale for CI or other developers.
Set up a private Git repository for each package (or a monorepo with tags). In the main app’s Package.swift, reference the package by its Git URL and a version requirement. For example: .package(url: "git@internal-git:team/NetworkingKit.git", from: "1.0.0"). When you update a package, tag a new version and update the dependency in the app.
CI pipelines need to handle package resolution. In a typical workflow, the CI machine clones the main app repo, then runs swift package resolve to fetch all dependencies. This can be slow if many packages are involved. Cache the .build folder between runs to speed up subsequent builds. Some teams use a local mirror of GitHub packages to avoid rate limits and network latency.
For teams using CocoaPods alongside SPM, be aware that mixing dependency managers can cause duplicate symbol errors if both include the same library. Gradually migrate CocoaPods dependencies to SPM where possible. Many popular libraries now support SPM natively. For those that don’t, wrap them in a thin SPM package that re-exports the library as a system module.
Comparison: Local vs. Remote Package Development
| Approach | Pros | Cons |
|---|---|---|
| Local path dependency | Instant changes, no Git overhead | Not shareable, breaks CI |
| Private Git repo per package | Versioned, shareable, CI-friendly | Requires tagging, more repos to manage |
| Monorepo with Git tags | Atomic commits, single repo | Complex tooling for versioning |
Variations for Different Constraints
Not every team has the same starting point. Here are three common scenarios and how to adapt the workflow.
Small Team, Single App
If you’re a team of one or two developers working on a single app, the overhead of multiple repositories may outweigh the benefits. In this case, keep all packages in a monorepo under a Packages folder. Use local path dependencies in the main app’s Package.swift. You won’t get versioning, but you will get faster builds and clearer separation. Once the team grows, you can move packages to separate repos and add versioning.
Large Team, Multiple Apps
When multiple apps share common modules, a shared package registry becomes critical. Set up a private Git server and create a CI pipeline that publishes packages to a registry (like Swift Package Registry or a simple Git tag-based system). Each app’s Package.swift references the registry. This allows the design system team to release a new version and have all apps update independently. The main challenge is coordinating breaking changes across apps; use semver and deprecation warnings to ease transitions.
Legacy Codebase with No Tests
Extracting modules from a codebase with no tests is risky because you don’t know what breaks. Start by adding integration tests for the parts you plan to extract. Write a test that calls the module’s public API and checks the result. Once you have a safety net, extract the module. After extraction, add unit tests to the package. This approach reduces the chance of introducing regressions.
Pitfalls, Debugging, and What to Check When It Fails
The most common SPM failure is a dependency cycle. If package A depends on B and B depends on A (directly or through a chain), Xcode will report a circular dependency error. Break the cycle by extracting the shared code into a third package that both A and B depend on. Use a dependency graph tool to visualize the graph before it becomes a problem.
Another frequent issue is version conflicts. If package A requires LoggingKit ~> 2.0 and package B requires LoggingKit ~> 1.5, SPM cannot resolve a single version. The fix is to update one package to use a compatible version, or to use a range that satisfies both. In some cases, you may need to fork a package and align versions.
Package resolution can also fail due to network issues or invalid Git references. If swift package resolve hangs, check your network and try clearing the package cache with swift package reset. If a specific package fails to fetch, verify the URL and that the tag exists. Xcode’s File > Packages > Reset Package Caches can help when the GUI gets stuck.
Access level problems are subtle. A class that was internal in the monolith becomes invisible when moved to a package, because the package’s internal is different from the app’s internal. You must mark types that are used outside the package as public. Use @testable import in test targets to access internal members for testing.
Finally, watch for resource bundling. SPM does not automatically bundle resources like images or storyboards. You need to declare resources in Package.swift using the resources parameter of a target. For assets used by multiple packages, consider creating a separate resource package or moving them to the main app target.
Common Error Messages and Fixes
- “Circular dependency” — Extract shared code into a new package.
- “No such module” — Check the package name in
Package.swiftand the import statement. - “Version resolution failed” — Use
swift package updateor adjust version constraints. - “Missing package product” — Ensure the product is defined in the package manifest.
Adopting SPM is a journey, not a single migration. Start with one module, learn the workflow, and then expand. The goal is not to have the most packages possible, but to have the right boundaries that make your team more productive. If a module isn’t paying off in build speed or code clarity, consider merging it back. Modularization is a tool, not a trophy.
Next steps: pick one utility module from your current app and try extracting it this week. Run the tests, measure the build time improvement, and discuss with your team whether the overhead is worth it for your context. Then decide on the next module. Over time, you’ll develop a sense for what makes a good package boundary, and the monolith will become a collection of well-defined, independently testable components.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!