The Best Practices for Swift Package Manager
Building Swift Packages That Stay Manageable
Every time I kick off a new Swift project, the first real call I make is how to handle dependencies. Swift Package Manager has been the standard since Swift 3.0, and honestly, it pulls its weight across iOS apps, server-side stuff, command-line tools—pretty much everything. The snag is that a lot of devs treat SPM like a file drop-in box and skip the architectural thinking that keeps a package sane over two or three years. I’ve made that mistake. Below I’ll walk through the habits I’ve settled into for creating, versioning, and testing Swift packages. The goal is code that doesn’t become a headache six months from now.

Treat Your Package Manifest Like a Public API
The Package.swift file isn’t a shopping list; it’s the contract you hand to anyone who pulls in your code. I start every manifest by defining products that expose only the bits other targets actually need. A newbie blunder is exporting every internal library under the sun, which leaks guts you’ll later want to change and ties consumers to details they shouldn’t see.
Since Swift 5.7, I’ve leaned on the package access level for shared utilities that aren’t meant for the outside world. It’s cleaner than sprinkling public everywhere. The symbol table stays tight, and you don’t get accidental coupling. When I lay out targets, I group them by job: a core target for the main logic, maybe a platform-adapters target for OS-specific glue, and a test utilities target that never shows up in the product list. It’s simple but stops a lot of mess.
Versioning So People Don’t Hate You
SPM leans hard on semantic versioning, and the version bounds you pick directly shape how easily others can adopt your changes. In applications, I lock dependencies to exact versions (.exact("1.2.0")). I want reproducible builds, full stop. For libraries, exact pins are a jerk move—they force resolution fights on anyone downstream. I use range-based rules (from: "1.2.0") so consumers get compatible updates without me micromanaging their dependency graph.
For my own packages, I’m religious about major.minor.patch. Patch means backward-compatible bug fixes and nothing else. Minor adds features but won’t break your existing calls. Major rips out or renames public API. Before I tag a release, I run swift package diagnose-api-breaking-changes. It’s a blunt instrument, but it compares public surfaces and yells if I’ve removed a declaration or changed a signature. I’ve been saved more than once from tagging a “minor” release that would’ve broken everyone.
Laying Out Code So You Can Find Things Later
I get asked constantly how to organize source files inside a package. SPM expects Sources/TargetName/ and Tests/TargetNameTests/. I push that further by mirroring the module structure in the actual folder layout. If a target has logical submodules, I make matching subdirectories and point SPM at them with path in the target definition. It’s not magic—just enough structure that I can open the project and know where to look.
Resources got a lot friendlier in Swift 5.3. I declare them explicitly with the resources parameter rather than coasting on defaults. Explicit rules make the build deterministic. For JSON fixtures and localised strings, .process("Resources/") is usually fine. For binary blobs like databases or pre-trained models, I switch to .copy("Assets/") so the directory layout stays untouched. I learned that one after an optimisation step mangled a file checksum I needed.

Keeping the Dependency Tangle in Check
SPM builds a version graph and picks the highest compatible version of each package. It works until you pull in packages that argue about transitive dependencies. I keep the tree shallow out of self-defence. For a typical library, my rule of thumb is zero or one external dependency. If I have to bring one in, I read its dependency list like a terms-of-service document—each extra package is another chance for a conflict.
Swift 5.9 gave us target-based dependency linking, and I’ve been using it to avoid hauling in entire packages when I only need one utility. Say a package vends a networking client and a logger. I link just the logger product if that’s all my target actually calls. It’s a small thing, but it keeps the build graph lean and speeds up resolution.
Testing That Catches Real Problems
I run tests with swift test in parallel by default and set up CI to hammer them across multiple Swift versions. The swift-tools-version line in the manifest is a promise about the minimum compiler you need, and I bump it only when I adopt a language feature that genuinely doesn’t exist in older compilers. Otherwise I’m locking out people on slightly older toolchains for no reason.
For packages that touch files or the network, I create test doubles in a separate TestSupport target. It’s not exported as a product, so consumers never see it, but every test target can depend on it. The pattern keeps helpers contained and stops them from swelling the package’s public footprint. I’ve also found that #if compilation conditions beat SPM’s file-exclude lists for platform-specific tests. A POSIX wrapper, for example, needs tests on macOS and Linux but should skip iOS. Conditional blocks keep the intent right in the source instead of scattering it across manifest rules.
Binary Frameworks Without the Trust Issues
Sometimes you can’t distribute source. For closed-source libraries, SPM lets you wrap a binary target in an .xcframework. I use binary targets only when I have to, and I always pin them with a checksum in the manifest. The checksum proves the archive I packaged is the one you downloaded—skip it, and you’re inviting supply-chain trouble.
When I ship a binary framework, I also push a companion source package that holds just the public interface stubs. Consumers develop against the stubs and switch to the real binary for release builds. Compile times stay low, and the compiler still checks that the API is used correctly without peeking at proprietary code. It’s a little bit of extra work up front that pays off every build.
Platform Requirements That Match Reality
SPM’s platforms array sets minimum deployment targets. I aim for the oldest OS version I’m actually willing to test on. Set the floor too high and you cut off users; set it too low and you’re writing compatibility hacks nobody verifies. I match the version to the oldest device sitting in my test lab, because if I can’t run the test suite on it, I shouldn’t claim support.
Conditional compilation like #if os(iOS) or #if canImport(UIKit) keeps cross-platform code in one logical place, but I cap conditionals at three blocks per file. Any more and the flow turns into puzzle solving. At that point I split platform variants into separate files and let the manifest’s source lists decide what gets compiled. It’s the same behaviour, but the code is easier to reason about.

Build Settings That Aren’t Afterthoughts
Build speed matters when a package does heavy work. SPM compiles targets in isolation, but the compiler can’t optimise across target boundaries unless you flip whole-module optimisation on. For release builds, I add -whole-module-optimization to the swiftSettings array. It makes compile times longer but the resulting binary faster—worth it for anything compute-heavy.
I also set up custom build flags for debugging without leaking internals. A DEBUG flag enables extra logging, and a TESTING flag exposes internal methods to test targets. Both are defined in the manifest under define and checked with #if DEBUG. Consumers never see them, so the public API stays clean.
Frequently Asked Questions
How do I fix “product not found” errors when adding a dependency?
That error means the package you’re pointing at doesn’t vend a product with the name you used. Open its manifest and look at the products array—the name must match exactly, capitals and all. If the package only defines targets and no products, you’ll need to ask the maintainer to add one, or fork it and add the product declaration yourself.
Should I commit Package.resolved to version control?
For applications, yes. Commit it to lock versions and get reproducible builds for everyone on the team. For libraries, don’t bother. SPM ignores the resolved file when your library is pulled in as a dependency, so committing it just adds noise. The standard .gitignore for Swift packages already excludes it.
How do I use Swift Package Manager with an existing Xcode project?
Xcode has a “Swift Packages” tab in project settings. Add a package by pasting its repository URL and picking a version rule. The dependencies show up in the project navigator, and you link them under “Frameworks, Libraries, and Embedded Content.” If you’re also using CocoaPods or Carthage, keep the SPM deps separate and never pull the same library through two managers—it only leads to symbol collisions and confusion.
What’s the right way to handle resource files in a Swift package?
Declare resources explicitly in the target definition with the resources parameter. For text-based stuff like JSON or string tables, .process lets SPM apply platform-appropriate optimisations. For binary assets, .copy preserves the exact structure. At runtime, grab them through Bundle.module, which SPM generates automatically for any target that lists resources.