Swift Modules and Code/Assets Duplication

Nowadays all my Xcode projects reference a Swift package that contains several modules. I try to make each cohesive part of my app a module. Several advantages apply; for my use cases, the most relevant are:

  • Clear, enforced boundaries

    Using real Swift modules (rather than just folders) enforces explicit public interfaces. Code in one module cannot access another without declaring a dependency, which helps prevent unintended coupling and surfaces architectural issues early. Xcode also understands these boundaries and generally avoids suggesting code completions for APIs that aren’t accessible from the current module, reducing noise and keeping autocomplete results focused and relevant.

  • Isolation and focus during development

    I can work on a single module without having to compile the rest of the app. This is especially useful when working on views and using Xcode previews.

  • Explicit dependencies

    Strong module boundaries make it easier to detect inappropriate dependencies. This encourages better abstractions and shared modules for truly common code.

If you have experience with The Composable Architecture (TCA) from Point-Free then you should be familiar with this project layout style. They discussed this in the episode "Modularization: Part 1".

However, while building my latest app – Shopie – I ran into an issue.

The Issue

Shopie is an app that lets you track prices and stock availability of products you are interested in buying. You'd add these products to the app while browsing in Safari or other apps. This meant Shopie had to include an Action app extension, so it could appear on the share sheet.

An Action app extension is a separate target in Xcode. With the setup described above — and because Swift modules are statically linked by default — my code ended up duplicated in the app-extension binary. That’s fine for a very small app with a single extension, but it could become problematic when the codebase grows and/or if I later add a Widget, Notification Service, or App Intents extension.

Moreover, every asset (images, localized strings, etc.) is also duplicated in any target that depends on the module. Xcode generates a <Package>_<Module>.bundle for each module that has resources and copies it into every target that uses it.

I could not be the first person to notice this issue. And sure enough, someone had already tackled it: this post by the folks at EmergeTools offers a solution. But their solution relies on creating xcframeworks, shell scripts, and binary SPM targets — which, to my mind, added too much complexity to solve what seemed like a simple problem.

Deduplicating Code

To deduplicate code across targets, the usual solution is to create a dynamic framework that all your targets can depend on. But our Swift modules are static by default.

We could make them all dynamic, but a better solution is to add a new "module" to your Swift package that merges every other module into a single dynamic framework. This module won't actually have any code. It's sole purpose is to aggregate all other modules.

This is pretty easy to do in your Package.swift:

let package = Package(
    name: "FooBar",
    defaultLocalization: "en",
    platforms: [
        .iOS(.v26)
    ],
    products: [
        .library(name: "FooBar",
                 type: .dynamic, // <-- Make FooBar a dynamic framework
                 targets: ["FooBar"])
    ],
    targets: [
        .target(name: "Foo"),
        .target(name: "Bar"),
        .target(name: "FooBar", dependencies: ["Foo", "Bar"]),
    ]
)

Here FooBar is a dynamic framework that includes both the Foo and the Bar module. Now have your app and its extensions depend on this library, and you're good to go1.

Deduplicating Assets

After creating the dynamic framework, if you inspect the app bundle you'll see something like this:

App.app
├── App
├── FooBar_Bar.bundle               <-- 1
├── FooBar_Foo.bundle               <-- 2
├── Frameworks
│   └── FooBar.framework
├── Info.plist
├── PkgInfo
└── PlugIns
    └── Share.appex
        ├── Base.lproj
        │   └── MainInterface.storyboardc
        ├── FooBar_Bar.bundle       <-- 1
        ├── FooBar_Foo.bundle       <-- 2
        ├── Info.plist
        └── Share

As you can see the FooBar_Bar and FooBar_Foo bundles are duplicated in the app directory as well as in the app-extension directory. Duplicating all assets like this can quickly increase your app size. Ideally, we'd want those bundles to be inside FooBar.framework. That's where the code that uses those assets already lives!

But first we need to make sure this is safe. Looking at the docs I found this:

When you build your Swift package, Xcode treats each target as a Swift module. If a target includes resources, Xcode creates a resource bundle and an internal static extension on Bundle to access it for each module. Use the extension to locate package resources. For example, use the following to retrieve the URL of a property list you bundle with your package:

let settingsURL = Bundle.module.url(forResource: "settings", withExtension: "plist")

So Xcode generates this module static extension that fetches the bundle. If we look at the generated code:

private class BundleFinder {}

extension Foundation.Bundle {
    /// Returns the resource bundle associated with the current Swift module.
    static let module: Bundle = {
        let bundleName = "FooBar_Bar"

        let overrides: [URL] = []
        #if DEBUG
        // The 'PACKAGE_RESOURCE_BUNDLE_PATH' name is preferred since the expected value is a path. The
        // check for 'PACKAGE_RESOURCE_BUNDLE_URL' will be removed when all clients have switched over.
        // This removal is tracked by rdar://107766372.
        if let override = ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_PATH"]
                       ?? ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_URL"] {
            overrides = [URL(fileURLWithPath: override)]
        } else {
            overrides = []
        }
        #else
        overrides = []
        #endif

        let candidates = overrides + [
            // Bundle should be present here when the package is linked into an App.
            Bundle.main.resourceURL,

            // Bundle should be present here when the package is linked into a framework.
            Bundle(for: BundleFinder.self).resourceURL,

            // For command-line tools.
            Bundle.main.bundleURL,
        ]

        for candidate in candidates {
            let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
            if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
                return bundle
            }
        }
        fatalError("unable to find bundle named FooBar_Bar")
    }()
}

The instruction Bundle(for: BundleFinder.self).resourceURL is what we're looking for. It checks the bundle the current code belongs to. So if the code is inside FooBar.framework, it will search it for FooBar_Bar.bundle.

So, in essence, we just need to copy the bundles to the framework and delete them from everywhere else.

Let's start by copying the bundles to the framework. We can do this with a Run Script phase in a new Aggregate target.

  1. Add a new Aggregate target named "FooBar" in Xcode.
  2. Add the FooBar dynamic library as a dependency of this target.
  3. Add a Run Script phase with the following code to copy the bundles to the framework:
    # Ensure BUILT_PRODUCTS_DIR is set
    if [ -z "$BUILT_PRODUCTS_DIR" ]; then
    echo "Error: BUILT_PRODUCTS_DIR is not set."
    exit 1
    fi
    
    SRC_DIR="$BUILT_PRODUCTS_DIR"
    
    # Find FooBar_*.bundle directories and define RSRC_BUNDLES
    RSRC_BUNDLES=$(
    cd "$SRC_DIR" || exit 1
    for b in FooBar_*.bundle; do
        [ -d "$b" ] && basename "$b" .bundle
    done
    )
    
    # Ensure we actually found some bundles
    if [ -z "$RSRC_BUNDLES" ]; then
    echo "Error: No FooBar_*.bundle directories found in $SRC_DIR"
    exit 1
    fi
    
    if [ "$ACTION" = "install" ]; then
        DEST_DIR="$BUILT_PRODUCTS_DIR/FooBar.framework"
    else
        DEST_DIR="$BUILT_PRODUCTS_DIR/PackageFrameworks/FooBar.framework"
    fi
    
    echo "Copying bundles to $DEST_DIR"
    echo "Bundles: $RSRC_BUNDLES"
    
    # Make sure destination exists
    mkdir -p "$DEST_DIR"
    
    for bundle in $RSRC_BUNDLES; do
        SRC="$SRC_DIR/$bundle.bundle"
        DEST="$DEST_DIR/$bundle.bundle"
    
        if [ -d "$SRC" ]; then
            echo "Copying $SRC$DEST"
            rm -rf "$DEST"
            cp -LR "$SRC" "$DEST"
        else
            echo "Warning: $SRC does not exist, skipping."
        fi
    done
    
  4. Finally, make your other targets depend on this new FooBar target instead of the dynamic framework.

Building the app again we can see the bundles inside the app, the framework, and the app extension. We just need to remove them from the app and the extension. Again, we can do this with a custom Run Script build phase in the main app target:

## Ensure BUILT_PRODUCTS_DIR is set
if [ -z "$BUILT_PRODUCTS_DIR" ]; then
  echo "Error: BUILT_PRODUCTS_DIR is not set."
  exit 1
fi

## Ensure EXECUTABLE_FOLDER_PATH is set
if [ -z "$EXECUTABLE_FOLDER_PATH" ]; then
  echo "Error: EXECUTABLE_FOLDER_PATH is not set."
  exit 1
fi

SRC_DIR="$BUILT_PRODUCTS_DIR"

## Find FooBar_*.bundle directories and define RSRC_BUNDLES
RSRC_BUNDLES=$(
  cd "$SRC_DIR" || exit 1
  for b in FooBar_*.bundle; do
    [ -d "$b" ] && basename "$b" .bundle
  done
)

## Ensure we actually found some bundles
if [ -z "$RSRC_BUNDLES" ]; then
  echo "Error: No FooBar_*.bundle directories found in $SRC_DIR"
  exit 1
fi

TARGET_DIR="$BUILT_PRODUCTS_DIR/$EXECUTABLE_FOLDER_PATH"

echo "Removing bundles from $TARGET_DIR"
echo "Bundles: $RSRC_BUNDLES"

## Function to remove bundles from a given directory
remove_bundles_from_dir() {
  local dir="$1"
  for bundle in $RSRC_BUNDLES; do
    local path="$dir/$bundle.bundle"
    if [ -d "$path" ]; then
      echo "Removing $path"
      rm -rf "$path"
    fi
  done
}

## 1) Check TARGET_DIR itself
remove_bundles_from_dir "$TARGET_DIR"

## 2) Check subdirectories, excluding FooBar.framework
find "$TARGET_DIR" -type d -maxdepth 2 ! -name "FooBar.framework" | while read -r dir; do
  remove_bundles_from_dir "$dir"
done

This script will remove all bundles from the app and extension targets, but won't touch FooBar.framework. After building, you can verify nothing is duplicated 🎉.

App.app
├── App
├── Frameworks
│   └── FooBar.framework
│       ├── FooBar
│       ├── FooBar_Bar.bundle
│       ├── FooBar_Foo.bundle
│       └── Info.plist
├── Info.plist
├── PkgInfo
└── PlugIns
    └── Share.appex
        ├── Base.lproj
        │   └── MainInterface.storyboardc
        ├── Info.plist
        └── Share

I've uploaded a sample project demonstrating this technique on GitHub. If you have comments, let me know on Mastodon!

Footnotes

  1. Just make sure you don't embed the dynamic framework in all targets — only the main app should embed it.