In a hundred-module iOS project the most frequently read file is usually Package.swift, and it is usually the worst written one too. Fifteen hundred lines of .target(...), each copied from the one above, three of them missing swiftSettings, one feature quietly depending on another feature, and nobody noticed. At the top sits a comment: "Please keep alphabetical order."
Local Swift packages carry this scale. There are places where they stop carrying it, and we name them at the end. But most of the trouble we see in large codebases does not come from SwiftPM's limits. It comes from writing the manifest as if it were a YAML file. Our running example is a hypothetical travel booking app: thirty-odd features, a dozen core modules, one widget extension. Tooling is Xcode 27 with Swift 6.4, and the manifest declares swift-tools-version: 6.2.
Whether a hundred modules is the right number for your project is a separate question, covered in the real cost of modularization. Here we assume you have already decided.
The first decision: one package, many targets
There are two ways to do this. Every module becomes its own local package with its own Package.swift, or a single Modules package holds a hundred-plus targets. We pick the second, for four reasons.
Dependency resolution. A hundred packages means a hundred manifests. Each one is compiled and evaluated separately, and every edge between them needs a .package(path:) line plus a product declaration. For Search to use HotelDetailInterface, the HotelDetail package must first publish it as a product. Inside one package, targets see each other by name and products are only the door to the app.
Access control. The package access level (SE-0386, Swift 5.9) exposes a symbol to other modules in the same package and hides it from everything else. The boundary is the package. With a hundred separate packages that level does the same job as internal, and everything shared between modules has to be public. With one package you mark a helper in FoundationKit as package and keep public for the API you want to treat like a third-party surface.
Xcode behaviour. Xcode creates a scheme for every library product of a local package, plus a -Package scheme per package. For our sample manifest xcodebuild -list returns three schemes: AppModules, WidgetModules, Modules-Package. With a hundred packages that list passes a hundred.
The manifest itself. One file, with the rules written once. There is no clean way to share the helpers you are about to see across a hundred manifests, because manifests cannot import each other.
The price of one package: Package.swift becomes a file everybody touches, and every edit reloads the whole graph. We accept that. We only consider multiple packages when a group of modules really is moving to another repository or another app, and then the number is three or four, never a hundred.
What the copy-paste manifest looks like
This is the state we see often in large codebases:
.target(
name: "Search",
dependencies: [
"Networking", "DesignSystem", "Analytics", "FoundationKit",
"HotelDetail", // "temporary", for navigation
.product(name: "Kingfisher", package: "Kingfisher"),
],
path: "Sources/Search",
resources: [.process("Resources")],
swiftSettings: [.enableUpcomingFeature("ExistentialAny")]
),
.target(
name: "HotelDetail",
dependencies: ["Networking", "DesignSystem", "Booking", "FoundationKit"],
path: "Sources/HotelDetail"
// swiftSettings forgotten
),
// ... 118 more targets
Three separate problems, none of which code review catches. Search depends on the implementation of HotelDetail, so every change in HotelDetail recompiles Search, and the day HotelDetail needs something from Search you have a cycle. Kingfisher has walked straight into a feature. Settings differ from target to target. Because the file is made of strings, the compiler objects to none of it.
The manifest is Swift: write the rules as types
Package.swift is a Swift program that gets executed. You can write enums, structs and functions. We describe modules in a typed table and generate the targets from it. The rule is simple: a feature can mention another feature only through uses, and uses only ever turns into an Interface target. There is no syntax for a feature-to-feature implementation dependency.
// swift-tools-version: 6.2
import PackageDescription
// MARK: - Module descriptions
/// The infrastructure features are built on. Every external library is
/// wrapped by exactly one module in this list.
enum Core: String, CaseIterable {
case foundationKit = "FoundationKit"
case networking = "Networking"
case persistence = "Persistence"
case analytics = "Analytics"
case imageLoading = "ImageLoading"
case designSystem = "DesignSystem"
var dependencies: [Core] {
switch self {
case .foundationKit: []
case .networking, .persistence, .analytics: [.foundationKit]
case .imageLoading: [.foundationKit, .networking]
case .designSystem: [.foundationKit, .imageLoading]
}
}
var external: [Target.Dependency] {
switch self {
case .imageLoading: [.product(name: "Kingfisher", package: "Kingfisher")]
default: []
}
}
var hasResources: Bool { self == .designSystem }
}
enum FeatureName: String {
case search = "Search"
case hotelDetail = "HotelDetail"
case booking = "Booking"
case payment = "Payment"
case profile = "Profile"
// ... 30 more features
}
struct Feature {
let name: FeatureName
/// Core modules used by the implementation.
var core: [Core] = [.foundationKit, .designSystem]
/// Core modules visible in the interface's public API. Keep it short.
var interfaceCore: [Core] = [.foundationKit]
/// Other features. Only ever resolved to their Interface targets.
var uses: [FeatureName] = []
var hasResources = false
}
let features: [Feature] = [
Feature(name: .search, core: [.foundationKit, .designSystem, .networking, .analytics],
uses: [.hotelDetail], hasResources: true),
Feature(name: .hotelDetail, core: [.foundationKit, .designSystem, .networking, .imageLoading],
uses: [.booking], hasResources: true),
Feature(name: .booking, core: [.foundationKit, .designSystem, .networking, .persistence],
uses: [.payment, .profile]),
Feature(name: .payment, core: [.foundationKit, .designSystem, .networking, .analytics]),
Feature(name: .profile, core: [.foundationKit, .designSystem, .networking, .persistence]),
]
// MARK: - Shared settings
let settings: [SwiftSetting] = [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault"),
]
extension Target.Dependency {
static func core(_ module: Core) -> Self { .target(name: module.rawValue) }
static func interface(_ feature: FeatureName) -> Self { .target(name: feature.rawValue + "Interface") }
static func testing(_ feature: FeatureName) -> Self { .target(name: feature.rawValue + "Testing") }
}
// MARK: - Target factories
func coreTargets(_ module: Core) -> [Target] {
let name = module.rawValue
return [
.target(
name: name,
dependencies: module.dependencies.map { .core($0) } + module.external,
path: "Core/\(name)/Sources",
resources: module.hasResources ? [.process("Resources")] : nil,
swiftSettings: settings
),
.testTarget(
name: name + "Tests",
dependencies: [.core(module)],
path: "Core/\(name)/Tests",
swiftSettings: settings
),
]
}
func featureTargets(_ feature: Feature) -> [Target] {
let name = feature.name.rawValue
let root = "Features/\(name)"
return [
// Contract: protocols, models, routes. Knows no other feature.
.target(
name: name + "Interface",
dependencies: feature.interfaceCore.map { .core($0) },
path: "\(root)/Interface",
swiftSettings: settings
),
// Implementation: its own interface, neighbours' interfaces, and core.
.target(
name: name,
dependencies: [.interface(feature.name)]
+ feature.uses.map { .interface($0) }
+ feature.core.map { .core($0) },
path: "\(root)/Sources",
resources: feature.hasResources ? [.process("Resources")] : nil,
swiftSettings: settings
),
// Fakes and fixtures. Never part of an app product.
.target(
name: name + "Testing",
dependencies: [.interface(feature.name)],
path: "\(root)/Testing",
swiftSettings: settings
),
.testTarget(
name: name + "Tests",
dependencies: [.target(name: name), .testing(feature.name)]
+ feature.uses.map { .testing($0) },
path: "\(root)/Tests",
swiftSettings: settings
),
]
}
// MARK: - Package
let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v17)],
products: [
// The single product the app target links.
.library(
name: "AppModules",
targets: features.map(\.name.rawValue) + Core.allCases.map(\.rawValue)
),
// The widget extension takes only what it needs.
.library(
name: "WidgetModules",
targets: [FeatureName.booking.rawValue + "Interface", Core.designSystem.rawValue]
),
],
dependencies: [
.package(url: "https://github.com/onevcat/Kingfisher.git", exact: "8.13.0"),
],
targets: Core.allCases.flatMap(coreTargets) + features.flatMap(featureTargets)
)
We ran this manifest, with five features and six core modules, through swift package describe and xcodebuild: it produces 32 targets. With thirty-five features the same file passes 150 targets and only the features table grows.
What this buys is concrete. Adding a feature is one line. A direct feature-to-feature dependency cannot be written, since uses takes a FeatureName and its only output is an Interface. Third-party libraries enter only through Core.external; the Feature type has no such field. InternalImportsByDefault (SE-0409) finishes the job at compiler level: import Kingfisher inside ImageLoading is internal by default, so Kingfisher types cannot leak into the module's public API. The same setting keeps feature Interface targets thin, because every module that appears in a public signature needs a deliberate public import.
Some rules the table cannot express (whether one Interface may depend on another, for example). Those need a separate check over the graph, and we covered the options in enforcing architecture rules.
Names, folders and the thin app target
Every feature is four targets and the names are not up for negotiation: Search, SearchInterface, SearchTesting, SearchTests. SwiftPM's default Sources/<Target> layout scatters those four across four roots. We use path: to keep them in the feature's own folder, because whoever deletes or moves a feature should deal with one directory.
TravelApp/
TravelApp.xcodeproj
App/ app target: @main, composition root, Info.plist
Widget/
Modules/
Package.swift
Core/
DesignSystem/
Sources/ (Resources/ lives here)
Tests/
Networking/ ...
Features/
Search/
Interface/ SearchInterface
Sources/ Search (screens, view models, previews)
Testing/ SearchTesting (fakes, fixtures)
Tests/ SearchTests
HotelDetail/ ...
Scripts/
affected-tests.swift
Previews live in the implementation target, in the same file as the view they preview. When a preview needs another feature's fake, that usually means the view knows too much; feeding the view plain values is a better fix than making implementations depend on Testing targets. Testing targets must never reach an app product, and the manifest guarantees that.
The app target contains three things: the entry point, configuration and the composition root. It is the only place where Interface protocols meet their implementations.
import Booking
import BookingInterface
import HotelDetail
import HotelDetailInterface
import Networking
import Search
import SwiftUI
@MainActor
final class CompositionRoot {
private let client = HTTPClient(baseURL: AppConfig.apiBaseURL)
// Search only knows the HotelDetailRouting protocol; it gets the concrete type here.
func makeSearch() -> some View {
SearchScreen(client: client, hotelDetail: hotelDetailRouter)
}
private var hotelDetailRouter: any HotelDetailRouting {
HotelDetailRouter(client: client, booking: BookingRouter(client: client))
}
}
This file grows, and that is fine. At four hundred lines you split it into one file per feature group. You do not hand the wiring back to the modules.
Resources, localization and Bundle.module
For every target with resources, the build produces a separate resource bundle and an internal Bundle.module accessor scoped to that module. Asset catalogs, .lproj folders, storyboards and Core Data models are recognised automatically; other files such as fonts must be declared through resources:. The .process("Resources") in the manifest does that for a whole folder.
The most common symptom is familiar: the screen shows search.title. A call without a bundle argument looked the string up in the app's main bundle. Every lookup inside a package has to name its bundle:
// Search/Sources/SearchScreen.swift
Text("search.title", bundle: .module)
let hint = String(localized: "search.empty.hint", bundle: .module)
Once you set defaultLocalization, Xcode expects localized resources in the package and reports missing ones at build time. We keep strings in the feature's own target; the "all strings in one module" approach recompiles every feature on every copy change.
Colours, icons and fonts live in DesignSystem. Bundle.module is internal, so other modules cannot reach that bundle directly; DesignSystem exposes its contents as public API. Fonts need extra care: the UIAppFonts key in Info.plist looks in the app's own bundle and does not see files in a package resource bundle. You register them yourself at launch.
// DesignSystem/Sources/Fonts.swift
import CoreText
import Foundation
public import SwiftUI
public enum Fonts {
/// Called once at app launch.
public static func register() {
let urls = Bundle.module.urls(forResourcesWithExtension: "ttf", subdirectory: nil) ?? []
for url in urls {
let registered = CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
assert(registered, "Failed to register font: \(url.lastPathComponent)")
}
}
}
public extension Color {
static let brandPrimary = Color("BrandPrimary", bundle: .module)
}
public extension Image {
static let emptyState = Image("EmptyState", bundle: .module)
}
Since Xcode 15, ImageResource and ColorResource symbols are generated for asset catalogs in packages as well. Use them inside the module, and give the outside world your own public names as above.
Linking: static by default, and the diamond trap
If you leave type off a .library product, the tool chooses, and Apple's documentation recommends exactly that. In practice the targets are built statically and end up inside whichever binary links the product. An app linking AppModules carries a hundred modules in a single executable. For launch time this is a good default, since dyld has no hundred frameworks to load.
An app and an extension using the same module is not a problem, but it is not free either. They are separate processes, so no symbols collide; a copy of the DesignSystem code goes into the app and another into the .appex. Expect the same for its resource bundle, and confirm by looking inside the .appex in your archive. Measure size before linking an asset-heavy design system into a widget; if needed, carve out a small WidgetKitUI module.
The real trap is the same module existing twice inside one process. The app links Networking statically, a dynamic framework embedded in the app also links Networking statically, and memory now holds two copies. You will recognise the symptoms: Class X is implemented in both ... in the console, "singletons" that get created twice, as? casts that fail between what looks like the same type. Since Xcode 12.5, Xcode builds a package product as a dynamic framework on its own when it detects this. Do not design around that logic: during the Xcode 27 betas precisely this diamond resolution broke, static copies multiplied, and the fix arrived as a separate swift-build pull request. Searching your logs for that warning on the Xcode version you actually ship with is a two-minute check.
You can also build the trap on purpose inside one package: declare two .dynamic products that share targets, and since targets within a package are linked statically into each product, the shared code sits in both frameworks. Our rule is short:
In the big package, never write
type:; declare one product per process.
Mergeable libraries (Xcode 15 and later) solve a different problem. With MERGED_BINARY_TYPE = automatic, dynamic frameworks are linked normally in debug builds and merged into the main binary in release builds: fast links while developing, launch behaviour close to static when shipping. According to the documentation, automatic merging covers framework and dylib targets in your project and XCFrameworks that carry mergeable metadata. Package targets that are already linked statically give it nothing to merge. Mergeable libraries start to matter the day you move your modules to Xcode framework targets; we have not verified how they behave with .dynamic package products, so try that in your own toolchain.
Where Xcode strains at this scale
Package resolution time is the price you pay on every project open and every branch switch. A single manifest is already the biggest saving here. The second is keeping the number of remote dependencies low and pinning each with exact:, which leaves the resolver nothing to choose.
Scheme inflation is proportional to product count. Two products, three schemes. For people working on one feature we keep a few hand-made shared schemes under Modules/.swiftpm/xcode/xcshareddata/xcschemes (a Search scheme that builds Search and SearchTests, for example). Adding a product per feature achieves the same thing but grows both the scheme list and the linking surface.
Since Xcode 16, SwiftUI previews share build products with build-and-run and build the same targets as the selected scheme. You feel the consequence: with the app scheme selected, a preview inside Search waits for the whole app to be up to date. Select a narrow scheme and the preview builds only that module's dependency closure. This is also where interface targets pay off: the closure of Search contains a handful of HotelDetail protocols and none of its screens.
For indexing we have no magic setting. The index lives in DerivedData, and the "something is broken, wipe DerivedData" habit means a full re-index every time on a hundred-module project. What helps more is not touching Package.swift without a reason: when the manifest changes, Xcode reloads the graph.
On the build side, compilation caching arrived in Xcode 26 (COMPILATION_CACHE_ENABLE_CACHING, off by default) and targets branch switches and clean builds. Turn it on and measure before and after on your own project with xcodebuild -showBuildTimingSummary; we give no number because the result depends on your module structure.
Third parties: pin them, wrap them, know the macro bill
Versions are written with exact: in the manifest and Package.resolved is committed. For the app project that file lives under TravelApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/. On CI, -disableAutomaticPackageResolution keeps the build from drifting away from it.
The manifest already applies the wrapping rule: every external library sits behind exactly one core module. The first benefit is compile surface, ahead of replaceability. If thirty features import Kingfisher, a version bump concerns thirty modules; if only ImageLoading does, it concerns one.
Binary targets (.binaryTarget(name:path:), or url with checksum) are unavoidable for closed-source SDKs. Wrap those too, and look at what is inside the XCFramework: a dynamic SDK that embeds a popular open-source library is the hardest variant of the diamond problem to debug.
// One new case in the Core enum and one target in the manifest:
.binaryTarget(name: "PaymentSDK", path: "Binaries/PaymentSDK.xcframework"),
// Features never see PaymentSDK; only the "PaymentGateway" core module depends on it.
Macros are a line item of their own. A macro package brings swift-syntax into the graph, and building it from source can be one of the longest steps of a clean build. Since Xcode 26, Xcode downloads a prebuilt swift-syntax from swift.org for macro targets, enabled by default. On the Swift 6.4 command line --enable-experimental-prebuilts is also the default, despite still being called experimental. Know the limits: the prebuilt is used only for macros, it applies when one has been published for the resolved swift-syntax version and your compiler, and otherwise SwiftPM quietly falls back to a source build. You find out which one you got by checking the build log for SwiftSyntax modules being compiled. Every macro package in the graph must agree on a single swift-syntax version, so each new macro dependency is one more version constraint. CI needs -skipMacroValidation to get past the macro trust prompt; use it only with pinned dependencies.
Our bar: a macro package gets in when the boilerplate it removes is truly widespread in the project. We do not pay this cost for a macro used in three places.
Tests: only the affected modules
Each module has its own test target, and all of them are in one test plan in the app project (Modules.xctestplan). Because the modules use UIKit and SwiftUI, you cannot run them with swift test on macOS; you need xcodebuild and a simulator.
Running all hundred and fifty test targets on a pull request is unnecessary. The approach is simple: map changed files to targets, walk the package graph backwards, select the test targets on the way. swift package describe --type json gives path, type and target_dependencies for each target; the rest is forty lines of Swift.
// Usage: git diff --name-only origin/main...HEAD | swift Modules/Scripts/affected-tests.swift Modules
import Foundation
struct Description: Decodable {
struct Target: Decodable {
let name: String
let path: String
let type: String
let target_dependencies: [String]?
}
let targets: [Target]
}
let packageDir = CommandLine.arguments.dropFirst().first ?? "Modules"
var changed: [String] = []
while let line = readLine() { changed.append(line) }
// Read the output of `swift package describe --type json`.
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["swift", "package", "--package-path", packageDir, "describe", "--type", "json"]
process.standardOutput = pipe
try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let targets = try JSONDecoder().decode(Description.self, from: data).targets
let tests = targets.filter { $0.type == "test" }.map(\.name).sorted()
// If the manifest, the lockfile or a file outside the package changed, run everything.
let prefix = packageDir + "/"
let global = changed.contains { file in
!file.hasPrefix(prefix) || file.hasSuffix("Package.swift") || file.hasSuffix("Package.resolved")
}
var affected = Set<String>()
if !global {
// 1. Targets that own the changed files.
for target in targets {
let root = prefix + target.path + "/"
if changed.contains(where: { $0.hasPrefix(root) }) { affected.insert(target.name) }
}
// 2. Reverse dependencies: whoever depends on an affected target is affected.
var grew = true
while grew {
grew = false
for target in targets where !affected.contains(target.name) {
if target.target_dependencies?.contains(where: affected.contains) == true {
affected.insert(target.name)
grew = true
}
}
}
}
let selected = global ? tests : tests.filter(affected.contains)
print(selected.map { "-only-testing:\($0)" }.joined(separator: " "))
In the sample package, a change under Features/Payment/Sources selects only PaymentTests. A change under Features/Payment/Interface adds BookingTests, because Booking consumes that contract. That is what small, stable interfaces are worth on CI. The script is deliberately crude: whenever it is unsure (manifest, lockfile, app code), it selects every test.
CI: what we cache and what we rebuild
set -euo pipefail
TESTS=$(git diff --name-only origin/main...HEAD | swift Modules/Scripts/affected-tests.swift Modules)
if [ -z "$TESTS" ]; then
echo "No affected test targets." # with no arguments xcodebuild would run everything
exit 0
fi
# $TESTS is deliberately unquoted: each -only-testing must be its own argument.
xcodebuild test \
-project TravelApp.xcodeproj -scheme TravelApp -testPlan Modules \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-clonedSourcePackagesDirPath .build/source-packages \
-disableAutomaticPackageResolution \
-skipMacroValidation \
$TESTS
The first thing we cache is the folder passed to -clonedSourcePackagesDirPath, keyed on the hash of Package.resolved. It is cheap, safe, and stops every job from cloning dependencies again.
We are cautious about carrying DerivedData between ephemeral runners. A fresh checkout changes file timestamps, so how much of the incremental state stays valid is uncertain, and downloading and unpacking a large archive can cost more than it saves. Do not leave this as an assumption: record job durations with and without the cache side by side for a week. If you have persistent runners, leaving DerivedData in place is usually the simplest win. Compilation caching is content-addressed and was designed for this problem, so it is the first thing we evaluate.
We run two lanes. Pull requests are incremental and selective: affected tests, cached dependencies. On main, a nightly clean build with every test. The nightly lane catches what the selective lane missed and what the cache was hiding; each one insures the other.
Migrating an existing project in steps
- Create an empty
Modulespackage, move onlyFoundationKit, and link the app against theAppModulesproduct. This tests your linking and resource assumptions on a single module. - Write the helpers before the tenth target. Converting a hundred copy-pasted targets into a table later is possible and tedious.
- Move the core modules, then extract
Interfacetargets starting with the feature that has the most dependents. Each extracted interface deletes a feature-to-feature edge. - The day nothing but
usescan express a feature dependency, the olddependencies: ["HotelDetail"]lines stop compiling. That is your signal the work is done. - Measure: clean build time on the same machine, incremental build time after editing one feature file, and the number of test targets run per pull request. Record all three before the migration as well.
Where plain SPM stops being enough
There are things SwiftPM does not know. It has two build configurations, debug and release, and you cannot write conditions for a custom one such as Staging. Apps, extensions and per-feature demo apps cannot be described in a package; they live in the .xcodeproj, and as they multiply the project file becomes a source of merge conflicts. Fine-grained control over linking (which module is dynamic, which is mergeable) ends at product level. And there is no built-in way to cache modules as prebuilt binaries.
When you hit two of these limits at once, we move to project generation: the module table stays, targets are generated as framework targets with Tuist or XcodeGen, and third-party dependencies remain in SwiftPM. Since your table is already typed data, the move is mechanical. The options are compared in detail in Tuist, SPM and xcodeproj. The signals: feature teams ask for demo apps, clean build time has become unacceptable without a binary cache, or debug link time makes up most of an incremental build. If none of those apply, stay on plain SPM; it is the setup with the fewest moving parts.
Checklist
- One local package; modules are targets, and the door to the app is one product per process.
Package.swiftgenerates targets from a typed table; a feature-to-feature implementation dependency cannot be written.- Every feature is four targets,
X,XInterface,XTesting,XTests, all in one folder. - No
Testingtarget appears in any app product. - Shared
swiftSettingsare defined once;InternalImportsByDefaultis on. - Every string, colour and image lookup inside the package passes
bundle: .module; fonts are registered at launch. - No product declares
type:; the logs were searched forimplemented in bothand came back clean. - The
.appexsize impact of modules linked into extensions has been measured. - Third-party versions are pinned with
exact:,Package.resolvedis committed, and each library sits behind one core module. - For dependencies that use macros, the build log was checked to see whether swift-syntax came prebuilt or from source.
- Pull requests run only affected test targets; the nightly lane runs a clean build with all of them.
- Clean and incremental build times are on record; the decision to move to project generation will be made from those numbers.
Need support on this?
Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.
Sources
- Apple · PackageDescription, library(name:type:targets:)
- Swift Evolution · SE-0386 package access modifier
- Apple · Bundling resources with a Swift package
- Apple · Localizing package resources
- Apple · Configuring your project to use mergeable libraries
- Apple · Xcode 26 Release Notes
- Apple · Xcode 12.5 Release Notes
- Apple · Building Swift packages or apps that use them in continuous integration workflows
- Swift Forums · Swift-Syntax Prebuilts for Macros
- swiftlang/swift-build · Fix product type checks in package diamond resolution
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.