Claude Skill

swiftpm-modularization

Default module shape for Apple-platform Swift Apps — one Swift Package, multiple targets, a thin App target (`@main` + DI root), CloudKit / GameKit / StoreKit imports confined to service targets, one test target per production target. Use when laying out targets in Package.swift,

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download wei18-apple-dev-skills-apple-dev-skills_skills_swiftpm-modularization-7ea7e61.zip · 4 KB
Part of wei18/apple-dev-skills — 37 skills

Install

skills CLI npx skills add https://github.com/wei18/apple-dev-skills/tree/main/apple-dev-skills/skills/swiftpm-modularization
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install wei18-apple-dev-skills@llmmart
Git git clone https://github.com/wei18/apple-dev-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole wei18/apple-dev-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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.swift maintenance cost and CI resolution time.

Very thin App target

  • The App target only contains:
    • @main, the App struct
    • Info.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

Core (pure Swift, no Apple frameworks)
   ↑
Domain (business logic / state)
   ↑
Service modules (CloudKit / GameKit / Storage / Telemetry)
   ↑
UI module (SwiftUI)
   ↑
App target

Restricted Apple framework imports

  • CloudKit is 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>KitTesting target 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/
            ├── <Core>/           # pure Swift, no Apple frameworks
            ├── <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 (multi-app monorepos only)

  • Without a committed Package.resolved, swift package resolve 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 fresh Package.resolved for 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.resolved to B and swap only the originHash (obtained from one throwaway resolve on B), preserving the file's JSON formatting; then verify swift build leaves the file byte-identical (no churn). Diff the full pin list against the reference, not just the one dependency a task happened to mention. Optionally run swift package resolve --force-resolved-versions to check the copied pins still satisfy B's manifest — that flag does not update originHash, so it doesn't replace the swap.

Renaming a target or test directory

  • swift build plus an import-site grep are 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, grep the repo's CI / task-runner / code-gen config for the old path string and run any gate that reads those paths locally to confirm it still resolves.

.xcassets inside a package target

  • swift test does not compile a package target's .xcassets at 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 LinkAssetCatalog step when the package is consumed from an Xcode project: both produce Assets.car for the same target, giving Multiple 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.
  • Keep asset catalogs in the App target; have package UI code read colors/images through injected tokens or Bundle.module resources that are not .xcassets.

Related skills

  • swift6-concurrency: Package applies swiftLanguageModes: [.v6] in one place. swift-tools-version: 6.2 is the shared gate for both platforms: [.iOS(.v26), ...] (apple-platform-targets) and swiftSettings: [.defaultIsolation(...)] (swift6-concurrency) — 6.0/6.1 reject both.
  • apple-platform-targets: Package platforms: aligned with App target.
  • swift-testing-baseline: test target framework and location.
  • telemetry-facade-pattern: why Telemetry is a standalone target.
  • Official sources: when verifying or updating a factual or version-sensitive claim, read references/official-docs.md.
Files (apple-dev-skills)
  • references
    • official-docs.md 1.7 KB
      Official pages backing this skill's claims; read when verifying or updating a factual or version-sensitive claim.
      
      | Page | URL | Backs |
      |---|---|---|
      | v26 | https://developer.apple.com/documentation/packagedescription/supportedplatform/iosversion/v26 | `.iOS(.v26)` requires SwiftPM 6.2 |
      | SE-0466 Control default actor isolation inference | https://github.com/swiftlang/swift-evolution/blob/main/proposals/0466-control-default-actor-isolation.md | `.defaultIsolation` |
      | swiftLanguageMode(_:_:) | https://developer.apple.com/documentation/packagedescription/swiftsetting/swiftlanguagemode(_:_:) | Target-level language mode |
      | Resolving package versions (SwiftPM docs, source) | https://github.com/swiftlang/swift-package-manager/blob/main/Sources/PackageManagerDocs/Documentation.docc/ResolvingPackageVersions.md | "resolves to those versions as long as they are still eligible"; `--force-resolved-versions` (the docs.swift.org mirror 404s as of 2026-09-13) |
      | Workspace+Dependencies.swift (source) | https://github.com/swiftlang/swift-package-manager/blob/main/Sources/Workspace/Workspace%2BDependencies.swift | A mismatched originHash triggers re-resolution and rewrites the file; `--force-resolved-versions` uses the lock file and does not write originHash -- keep the originHash-replacement step |
      | Bundling resources with a Swift package | https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package | `.xcassets` section: `Bundle.module` |
      | Getting Started with the Swift SDK for Android | https://www.swift.org/documentation/articles/swift-sdk-for-android-getting-started.html | "When to invoke" section: porting core logic to Android (HTTP 200; JS-rendered, content not verbatim-verified) |
      
  • SKILL.md 7.1 KB
    ---
    name: swiftpm-modularization
    description: 'Default module shape for Apple-platform Swift Apps — one Swift Package, multiple targets, a thin App target (`@main` + DI root), CloudKit / GameKit / StoreKit imports confined to service targets, one test target per production target. Use when laying out targets in Package.swift, deciding where a new module or framework import lives, planning core portability (Swift on Android), when `.xcassets` go missing in a package target, or when asked "single package or multi-package". Does NOT own `platforms:` → apple-platform-targets, `swiftLanguageModes` → swift6-concurrency, or the test framework → swift-testing-baseline.'
    ---
    
    # 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.swift` maintenance cost and CI resolution time.
    
    ### Very thin App target
    
    - The App target only contains:
      - `@main`, the `App` struct
      - `Info.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
    
    ```
    Core (pure Swift, no Apple frameworks)
       ↑
    Domain (business logic / state)
       ↑
    Service modules (CloudKit / GameKit / Storage / Telemetry)
       ↑
    UI module (SwiftUI)
       ↑
    App target
    ```
    
    ### Restricted Apple framework imports
    
    - `CloudKit` is 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>KitTesting` target 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/
                ├── <Core>/           # pure Swift, no Apple frameworks
                ├── <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 (multi-app monorepos only)
    
    - Without a committed `Package.resolved`, `swift package resolve` 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 fresh `Package.resolved` for 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.resolved` to B and swap only the `originHash` (obtained from one throwaway resolve on B), preserving the file's JSON formatting; then verify `swift build` leaves the file byte-identical (no churn). Diff the **full** pin list against the reference, not just the one dependency a task happened to mention. Optionally run `swift package resolve --force-resolved-versions` to check the copied pins still satisfy B's manifest — that flag does not update `originHash`, so it doesn't replace the swap.
    
    ### Renaming a target or test directory
    
    - `swift build` plus an import-site `grep` are 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, grep the repo's CI / task-runner / code-gen config for the old path string and run any gate that reads those paths locally to confirm it still resolves.
    
    ### `.xcassets` inside a package target
    
    - `swift test` does not compile a package target's `.xcassets` at 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 `LinkAssetCatalog` step when the package is consumed from an Xcode project: both produce
      `Assets.car` for the same target, giving `Multiple 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.
    - Keep asset catalogs in the App target; have package UI code read colors/images through injected tokens or `Bundle.module` resources that are not `.xcassets`.
    
    ## Related skills
    
    - `swift6-concurrency`: Package applies `swiftLanguageModes: [.v6]` in one place. `swift-tools-version: 6.2` is the shared gate for both `platforms: [.iOS(.v26), ...]` (`apple-platform-targets`) and `swiftSettings: [.defaultIsolation(...)]` (`swift6-concurrency`) — 6.0/6.1 reject both.
    - `apple-platform-targets`: Package `platforms:` aligned with App target.
    - `swift-testing-baseline`: test target framework and location.
    - `telemetry-facade-pattern`: why `Telemetry` is a standalone target.
    - Official sources: when verifying or updating a factual or version-sensitive claim, read `references/official-docs.md`.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related