Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 054 / 100

Swift Package Manager: a Swift & iOS guide for developers

Manage dependencies and modules.

Expona Admin8 min read

Swift Package Manager: a Swift & iOS guide for developers

Dependency management can make or break your iOS project—too many external packages and you're maintaining someone else's code; too few and you're reinventing the wheel. Swift Package Manager (SPM) sits at the centre of this balance, giving you native tooling to manage dependencies and split your app into maintainable modules without reaching for CocoaPods or Carthage. In this guide, we'll walk through adding packages, pinning versions, creating local packages, and making deliberate choices about what code you actually need.

Understanding Swift Package Manager basics

Swift Package Manager is Apple's official dependency manager, built into Xcode and the Swift toolchain. Unlike CocoaPods (which runs as a plugin) or Carthage (which requires manual setup), SPM is integrated directly into Xcode's build system. This means no separate configuration files to maintain, no build phase scripts, and seamless integration with your project's build graph.

SPM packages are simply Swift modules with a Package.swift manifest file that describes targets, dependencies, and version constraints. Each package is a discrete unit of code you can version, test, and reuse across multiple projects. When you add a package to your iOS app, Xcode clones or downloads it, resolves its dependencies, and links it into your build.

Add packages via Xcode's UI

The fastest way to add a package to your iOS app is through Xcode's built-in interface. Open your project, select the target, and navigate to File → Add Package Dependencies. This opens a dialog where you paste the repository URL (HTTPS or SSH), select your version constraint, and choose which targets should link the package.

Let's say we want to add the popular Alamofire networking library. Here's the workflow:

  1. In Xcode, go File → Add Package Dependencies
  2. Paste https://github.com/Alamofire/Alamofire.git
  3. Set the version rule (e.g., "Up to Next Major Version 5.8.0")
  4. Select your app target from the "Add to Project" dropdown
  5. Click "Add Package"

Xcode clones the repository, resolves its dependency tree, and updates your Package.resolved file. You can now import Alamofire in your Swift files:

swift
import Alamofire

func fetchUserData() {
    AF.request("https://api.example.com/user")
        .responseDecodable(of: User.self) { response in
            switch response.result {
            case .success(let user):
                print("User: \(user.name)")
            case .failure(let error):
                print("Error: \(error)")
            }
        }
}

The UI approach works well for simple cases, but you can also edit your Package.swift directly for fine-grained control or when scripting your setup.

Pin versions with Package.resolved

When you add a package, Xcode generates a Package.resolved file that locks each dependency to a specific commit, tag, or version. This file is crucial for reproducible builds—it ensures that every team member and CI/CD pipeline uses exactly the same package versions, preventing the "works on my machine" problem.

Always commit Package.resolved to version control. This is non-negotiable. If you ignore it or add it to .gitignore, your builds will drift over time as dependencies are re-resolved, and you'll spend hours debugging inconsistent issues.

Version constraints in SPM come in several flavors. In your project's Package.swift manifest, you specify ranges that Xcode uses to resolve packages:

Constraint Meaning Example
Exact version Lock to a specific tag .exact("1.2.3")
Up to Next Major Allow minor and patch updates 1.2.3..<2.0.0
Up to Next Minor Allow only patch updates 1.2.3..<1.3.0
From version Accept any version from this point onward .upToNextMajor(from: "1.0.0")

When you run a build or add a new dependency, Xcode resolves all version constraints against what's available in the remote repository, then locks the result in Package.resolved. To update packages to newer versions within your constraints, go to File → Update Package Dependencies.

A sample Package.swift file for an iOS app might look like this:

swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "MyApp",
    dependencies: [
        .package(url: "https://github.com/Alamofire/Alamofire.git", 
                 .upToNextMajor(from: "5.8.0")),
        .package(url: "https://github.com/realm/realm-swift.git", 
                 .upToNextMinor(from: "10.45.0"))
    ],
    targets: [
        .target(
            name: "MyApp",
            dependencies: ["Alamofire", "RealmSwift"]
        )
    ]
)

When your team pulls this, Xcode reads Package.resolved and installs the exact same versions without re-resolving, guaranteeing consistency.

Create local packages to split your app into modules

One of SPM's best features is the ability to create local packages—modules that live inside your Xcode workspace but are versioned and built as separate packages. This lets you split a monolithic app into focused, reusable layers without publishing to a remote repository.

Imagine you're building an e-commerce app. You might create local packages for:

  • NetworkClient — API requests, session management, authentication
  • Models — Codable data structures shared across the app
  • DesignSystem — Shared UI components, colors, typography
  • UserFeature — Profile, authentication flows
  • ProductFeature — Product listing, detail views

To create a local package, go File → New → Package in Xcode. This generates a folder with the standard SPM structure:

swift
NetworkClient/
├── Package.swift
├── Sources/
│   └── NetworkClient/
│       └── HTTPClient.swift
└── Tests/
    └── NetworkClientTests/
        └── HTTPClientTests.swift

Then, in your main app's File → Add Package Dependencies, select "Add Local…" and choose the package folder. Xcode adds it to your workspace and links it to your app target.

A local Package.swift might look like:

swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "NetworkClient",
    platforms: [
        .iOS(.v15)
    ],
    products: [
        .library(name: "NetworkClient", targets: ["NetworkClient"])
    ],
    dependencies: [
        .package(url: "https://github.com/Alamofire/Alamofire.git", 
                 .upToNextMajor(from: "5.8.0"))
    ],
    targets: [
        .target(
            name: "NetworkClient",
            dependencies: ["Alamofire"],
            path: "Sources/NetworkClient"
        ),
        .testTarget(
            name: "NetworkClientTests",
            dependencies: ["NetworkClient"]
        )
    ]
)

Local packages enforce clear boundaries. The NetworkClient package can't import anything from ProductFeature, preventing circular dependencies and tangled coupling. Each package has its own tests, its own dependencies, and its own versioning logic. When you later want to open-source or share code across multiple apps, you can extract that package, push it to a git repository, and convert it from local to remote with minimal friction.

Keep dependencies lean—fewer packages mean less maintenance

Every external package you add is code your team must maintain, monitor for security updates, and troubleshoot when it breaks. The temptation is strong: "Let's use a library for that instead of building it ourselves." But this decision has a cost.

Consider a simple example. You need JSON date parsing in your models. You might reach for a third-party package that handles 50 different date formats. Or you could write a small Codable helper:

swift
struct FlexibleDateDecoder {
    static let iso8601Full: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
        return formatter
    }()
    
    static let dateOnly: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd"
        return formatter
    }()
}

extension JSONDecoder {
    func customDateStrategy() {
        self.dateDecodingStrategy = .custom { decoder in
            let container = try decoder.singleValueContainer()
            let dateString = try container.decode(String.self)
            
            if let date = FlexibleDateDecoder.iso8601Full.date(from: dateString) {
                return date
            } else if let date = FlexibleDateDecoder.dateOnly.date(from: dateString) {
                return date
            } else {
                throw DecodingError.dataCorruptedError(
                    in: container,
                    debugDescription: "Invalid date format"
                )
            }
        }
    }
}

This 30-line helper covers your use case. It's in your codebase, you understand every line, and there are zero external dependencies. When an API response format changes, you update this one file. Compare this to managing a third-party date library: checking its changelog, updating when vulnerabilities are found, understanding its API, and hoping it aligns with your needs.

Our rule of thumb: only add a package if it solves a complex problem or is from a team you trust to maintain it long-term. For e-commerce, ride-hailing, and SaaS codebases, typical "must have" packages are:

  • Networking (Alamofire, URLSession + helpers)
  • Database (Realm, SQLite, Core Data)
  • Analytics or crash reporting (if your company uses them)
  • Authentication SDKs (Firebase, Auth0)
  • UI frameworks (if you're not building custom)

Avoid packages for small utilities, UI components you can build quickly, or business logic specific to your app. Keep your dependency tree shallow and wide, not deep and tangled.

Common mistakes

Ignoring Package.resolved in version control. This is the most common mistake. Developers add it to .gitignore, thinking it's generated. Don't. Every team member must have identical package versions. Commit it, review it in PRs, and treat it like any other project file.

Using too loose version constraints. Specifying .upToNextMajor(from: "1.0.0") means you'll accept version 5.0.0 if it exists. Major version bumps often include breaking changes. Use .upToNextMinor for stability unless you actively monitor and test new major versions.

Adding packages without clear need. Every package adds build time, disk space, and potential security surface. Before adding a dependency, ask: "Can we write this in 50 lines of code?" If yes, write it yourself.

Creating too many local packages. While local packages encourage modularity, creating a package for every feature can lead to over-engineering. Aim for 3–5 well-focused local packages per app, not 20 tiny ones that create maintenance overhead.

Forgetting to set deployment targets. If your local package targets iOS 16 but your app targets iOS 14, the build will fail. Always specify platform constraints in your Package.swift to match your app's deployment target.

Key takeaways

  • Use Xcode's File → Add Package Dependencies to integrate remote packages quickly, and always commit the resulting Package.resolved file to enforce reproducible builds across your team.
  • Choose version constraints carefully. Prefer .upToNextMinor for stability, and only use .upToNextMajor when you actively manage breaking changes.
  • Build local packages to split your app into focused modules. NetworkClient, DesignSystem, and feature modules clarify boundaries and make testing, sharing, and refactoring easier.
  • Be ruthless about dependencies. Every package is code you don't own. If you can write it in under an hour and it's core to your product, write it yourself and own the maintenance burden.
  • Test your dependencies early. Add a package, build it, run tests, and verify it integrates smoothly before committing to your project. Some packages have incompatibilities or bloat your app size in ways that don't become obvious until you ship.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles