SwiftPM Modularization
When to invoke
- Starting a new Swift App project and deciding how to split modules.
- Writing the first version of
Package.swift. - Wanting to reserve the option of shipping core logic to Android / cross-platform later.
- Introducing CloudKit / GameKit / StoreKit and deciding the import scope.
- User asks "single Package or multiple", "should the App target be thin", "how do I wire DI".
Default decisions
Single Package + multiple targets
- Put all modules in one Swift Package, splitting by target (named e.g.
<Project>Kit). - Don't start with multiple Packages — they only add
Package.swiftmaintenance cost and CI resolution time.
Very thin App target
- The App target only contains:
@main, theAppstructInfo.plist, entitlements- Assets / Asset Catalog
- A single DI composition root (wiring protocols to concrete implementations)
- All views, logic, and Storage live in the Package.
- The App target has no unit tests — keep it free of logic so nothing there needs one; end-to-end launch tests live in
host-driven-xcuitest-e2e. All testable logic is in the Package.
Dependencies flow upward, never downward
Engine (pure Swift core)
↑
GameState / Domain
↑
Service modules (CloudKit / GameKit / Storage / Telemetry)
↑
UI module (SwiftUI)
↑
App target
Restricted Apple framework imports
CloudKitis imported only in its designated service target.- Same for
GameKit/StoreKit. - The UI and logic layers consume these via injected protocols, never importing the framework directly.
- This is the precondition for "core ports to Android / Linux" (Swift on Android can consume pure Swift modules directly).
One test target per production target
- Each production target has a matching test target named
<Module>Tests. - Shared fakes / stubs can be factored into a separate
<Project>KitTestingtarget imported by multiple test targets.
Rationale
- Single Package: the App's modules have no need for external publication, so multi-Package's marginal cost outweighs the benefit.
- Thin App target: SwiftUI previews can run straight from the Package, yielding the fastest preview iteration loop.
- Restricted framework imports: enables unit testing, keeps previews free of permission dialogs, and preserves the portability path.
- One-to-one test targets: dependencies are clear, and CI can run only the modules that matter (paired with selective testing tooling).
Deviation considerations
- A module needs to be published externally: upgrade to multi-Package; usually defer until the need is real.
- A third-party dep is so heavy it harms build time: pin it inside a single target and fan out from logic layers.
- Sharing across multiple Apps: extract into a standalone repo Swift Package.
Example shape
<Project>/
├── App/ # thin shell
│ ├── <Project>App.swift # @main + DI composition root
│ └── (Assets, Info.plist, entitlements)
└── Packages/
└── <Project>Kit/
├── Package.swift
└── Sources/
├── <Engine>/ # pure Swift core
├── <Domain>/ # domain logic
├── <Storage>/ # service module (CloudKit import restricted here)
├── <Telemetry>/ # Logger / Tracking facade
└── <UI>/ # SwiftUI Views
└── Tests/
└── <Module>Tests/ # one-to-one
Common footguns
Pin parity across sibling apps
swift package resolvealways re-resolves to the newest version each dependency's range allows — it does not consult a sibling app's committed pins. Running it to "materialize" a freshPackage.resolvedfor a second app silently drifts its pins away from the first app's committed versions.- To give app B pin-parity with app A: copy A's committed
Package.resolvedto B and swap only theoriginHash(obtained from one throwaway resolve on B), preserving the file's JSON formatting; then verifyswift buildleaves the file byte-identical (no churn). Diff the full pin list against the reference, not just the one dependency a task happened to mention.
Renaming a target or test directory
swift buildplus an import-sitegrepare not sufficient verification for a target/test-directory rename. Non-Swift tooling — CI workflow files, task runners, code-gen scripts — often hard-code the path string, which compiles fine and passes the import grep but breaks at the tooling layer.- Before pushing a rename, also
grep -rn '<OldName>' <tooling dirs> .github ci_scriptsand run any gate that reads those paths (e.g. localization or fixture generation) locally to confirm it still resolves.
.xcassets inside a package target
swift testdoes not compile a package target's.xcassetsat all — asset-catalog resources are silently invisible to the plain SwiftPM test runner.- Adding a SwiftPM build-tool plugin to compile the catalog yourself then collides with Xcode's
own
LinkAssetCatalogstep when the package is consumed from an Xcode project: both produceAssets.carfor the same target, givingMultiple commands produce …Assets.car. - The common guard of checking for a
/SourcePackages/plugins/path does not reliably tell you whether the build is happening under Xcode — don't rely on it to skip the plugin conditionally.
Related skills
swift6-concurrency: Package appliesswiftLanguageModes: [.v6]in one place.apple-platform-targets: Packageplatforms:aligned with App target.swift-testing-baseline: test target framework and location.telemetry-facade-pattern: whyTelemetryis a standalone target.