Multi-Package Architecture
Throughout this series we've built everything inside a single LandmarksPackage. That works well up to a point. But as your team and feature count grow, a single package starts showing strain. The Package.swift becomes a wall of targets. Everyone's PRs touch the same manifest file. And when someone breaks a module deep in the dependency graph, every developer's build fails until it's fixed.
The solution is the same one we applied to the app itself: split things up along domain boundaries. Instead of one package with fifteen modules, you have three or four packages, each owning a clear slice of functionality. A shared Common package provides the foundation they all build on.
You can find the complete working code for this post on GitHub.
The target architecture
Here's what we're moving toward:
Workspace/
├── CommonPackage/ # Env, Logger, Toolkit, DesignSystem
├── LandmarksPackage/ # LandmarksApi, LandmarksDomain, LandmarksFeature
├── SocialPackage/ # SocialApi, SocialDomain, SocialFeature
└── App/ # Main app target
Each package owns a complete vertical slice: API, Domain, and Feature for its domain. The Common package provides shared utilities that every other package depends on.
CommonPackage
The Common package extracts what was previously the Common layer of the monolithic package:
// CommonPackage/Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "CommonPackage",
platforms: [.iOS(.v18)],
products: [
.library(name: "Env", targets: ["Env"]),
.library(name: "Logger", targets: ["Logger"]),
.library(name: "Toolkit", targets: ["Toolkit"]),
.library(name: "DesignSystem", targets: ["DesignSystem"]),
],
targets: [
.target(name: "Env"),
.target(name: "Logger"),
.target(name: "Toolkit"),
.target(name: "DesignSystem"),
]
)
This package has zero external dependencies. It's the foundation that everything else builds on. In practice, this package rarely changes. That stability is valuable because every other package depends on it.
Feature packages depend on Common
Each feature package declares a path dependency on CommonPackage:
// LandmarksPackage/Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "LandmarksPackage",
platforms: [.iOS(.v18)],
products: [
.library(name: "LandmarksDomain", targets: ["LandmarksDomain"]),
.library(name: "LandmarksFeature", targets: ["LandmarksFeature"]),
],
dependencies: [
.package(path: "../CommonPackage"),
],
targets: [
.target(
name: "LandmarksApi",
dependencies: [
.product(name: "Env", package: "CommonPackage"),
.product(name: "Toolkit", package: "CommonPackage"),
]
),
.target(
name: "LandmarksDomain",
dependencies: [
"LandmarksApi",
.product(name: "Env", package: "CommonPackage"),
.product(name: "Toolkit", package: "CommonPackage"),
]
),
.target(
name: "LandmarksFeature",
dependencies: [
"LandmarksDomain",
.product(name: "DesignSystem", package: "CommonPackage"),
.product(name: "Env", package: "CommonPackage"),
.product(name: "Toolkit", package: "CommonPackage"),
]
),
// Preview Apps
.executableTarget(
name: "LandmarksPreviewApp",
dependencies: ["LandmarksFeature", "LandmarksDomain"]
),
// Tests
.testTarget(
name: "LandmarksDomainTests",
dependencies: ["LandmarksDomain"]
),
]
)
The key line is .package(path: "../CommonPackage"). This is a local path dependency, resolved at build time relative to the package directory. SPM resolves it just like a remote dependency but without any fetching.
Cross-package domain dependencies
Sometimes one feature package needs types from another. For example, if SocialPackage has a "share landmark" feature, it needs the Landmark type from LandmarksPackage. Handle this with another path dependency:
// SocialPackage/Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "SocialPackage",
platforms: [.iOS(.v18)],
products: [
.library(name: "SocialDomain", targets: ["SocialDomain"]),
.library(name: "SocialFeature", targets: ["SocialFeature"]),
],
dependencies: [
.package(path: "../CommonPackage"),
.package(path: "../LandmarksPackage"),
],
targets: [
.target(
name: "SocialApi",
dependencies: [
.product(name: "Env", package: "CommonPackage"),
.product(name: "Toolkit", package: "CommonPackage"),
]
),
.target(
name: "SocialDomain",
dependencies: [
"SocialApi",
.product(name: "LandmarksDomain", package: "LandmarksPackage"),
.product(name: "Env", package: "CommonPackage"),
.product(name: "Toolkit", package: "CommonPackage"),
]
),
.target(
name: "SocialFeature",
dependencies: [
"SocialDomain",
.product(name: "DesignSystem", package: "CommonPackage"),
.product(name: "Env", package: "CommonPackage"),
.product(name: "Toolkit", package: "CommonPackage"),
]
),
]
)
SocialDomain depends on LandmarksDomain from LandmarksPackage. It gets the Landmark type and LandmarkService without pulling in any of the Landmarks feature UI. The dependency is at the domain level only.
Be careful with cross-package domain dependencies. They create a coupling between packages. The rule of thumb: Domain-to-Domain dependencies are fine. Feature-to-Feature dependencies are not. If a feature needs to navigate to another feature's view, that wiring happens in the app layer.
Workspace layout
The directory structure for a multi-package workspace looks like this:
MyApp/
├── MyApp.xcodeproj # or .xcworkspace
├── MyApp/
│ ├── App.swift # @main entry point
│ └── ContentView.swift
├── CommonPackage/
│ ├── Package.swift
│ └── Sources/
│ ├── Env/
│ ├── Logger/
│ ├── Toolkit/
│ └── DesignSystem/
├── LandmarksPackage/
│ ├── Package.swift
│ └── Sources/
│ ├── LandmarksApi/
│ ├── LandmarksDomain/
│ ├── LandmarksFeature/
│ └── LandmarksPreviewApp/
└── SocialPackage/
├── Package.swift
└── Sources/
├── SocialApi/
├── SocialDomain/
├── SocialFeature/
└── SocialPreviewApp/
Add each package to your Xcode project by dragging the package folder into the project navigator. Xcode resolves the path dependencies automatically. Each package shows up as a separate entry with its own targets and schemes.
The app target
The main app's Package.swift dependencies (or Xcode project package dependencies) reference all feature packages:
dependencies: [
.package(path: "CommonPackage"),
.package(path: "LandmarksPackage"),
.package(path: "SocialPackage"),
]
And the app target links against the feature and domain libraries it needs:
import SwiftUI
import LandmarksDomain
import LandmarksFeature
import SocialDomain
import SocialFeature
import Toolkit
@main
struct MyApp: App {
let services: ServiceEnvironment = .live(
client: .default,
baseURL: URL(string: "https://api.example.com")!
)
var body: some Scene {
WindowGroup {
ContentView()
.withServiceEnvironment(services)
}
}
}
The ServiceEnvironment now aggregates services from multiple packages. Its definition lives wherever makes sense for your app. If it grows to include services from many packages, consider putting it in a thin "AppCore" library that the app target depends on.
When to split into multiple packages
A single package is fine until it's not. Here are signs you should split:
The Package.swift is hard to navigate. If it's over 100 lines and growing, separate packages make each manifest focused and readable.
Different teams own different features. Package boundaries align with team boundaries. Team A owns LandmarksPackage, Team B owns SocialPackage. PRs stay scoped.
Build times suffer from unnecessary recompilation. When packages are separate, SPM caches each package independently. A change in SocialPackage doesn't invalidate LandmarksPackage's build cache.
You want to reuse a domain across apps. If your CommonPackage or LandmarksPackage could serve multiple apps, separating them makes that possible. Point at a git URL instead of a path, and you have a reusable dependency.
Don't split prematurely. A single package with good module boundaries (which we built in parts 1-5) is much simpler to work with. Split when the single package actively causes problems, not as a preventive measure.
Managing shared types
One challenge with multiple packages: where do shared protocols and types live? The answer is almost always CommonPackage.
ApiModelandDomainModelprotocols go inToolkitEndpoint,NetworkClient, andEmptyBodygo inToolkit- Design system components go in
DesignSystem - Environment configuration goes in
Env
If a type is used by two or more packages, it belongs in Common. If it's only used within one package, it stays there.
Versioning strategy
For local path dependencies, versioning isn't an issue. All packages are in the same repo (or adjacent directories) and always build from source. But if you ever need to extract a package into its own repository (for sharing across apps), you'll want semantic versioning:
// Remote dependency with version
.package(url: "https://github.com/myorg/CommonPackage.git", from: "1.0.0"),
Start with path dependencies. Move to versioned remote dependencies only when you have a concrete need, like sharing across multiple apps or teams with different release cadences.
Series recap
Over six posts, we've built a complete modular architecture for a Swift app:
- Modularizing Swift Apps with SPM - The three-layer pattern: Common, Services, Features
- Adding Reservations - Adding a second domain proves the pattern scales
- Wiring It All Together - The main app as a thin composition root
- Preview Apps - Executable targets for fast, focused iteration
- Design System - Shared UI components in the Common layer
- Multi-Package Architecture - Splitting into multiple packages for team scale
The progression was intentional. We started with the simplest useful structure (one package, one feature) and added complexity only when we had a concrete reason. Each post introduced one new concept and built on everything before it.
The architecture we ended up with gives you compiler-enforced module boundaries, fast incremental builds, previews that work without a server, and clear ownership for every line of code. It's not the only way to structure a Swift app, but it's a pragmatic one that scales from side project to team project without needing to be rewritten.