How to build a modular whitelabel app with local swift packages?

I am currently working on the architecture of a whitelabel app for iOS that should build into four brands.

Upfront: I apologise for the wall of text below. Since the multiple things I have tried revolve around an entire build system configuration and are spread out over various places, it is quite hard to give concise code examples.

Project setup

The top level xcodeproj has one target and scheme for each brand with Debug and Release configurations, where Debug is built locally and Release is built from CI only. The CI injects all parameters to flavour the build into snapshots, release candidates, store builds and so on.

Feature modules (read: packages) are in the Modules/ folder of the project and included into the targets as dependencies.

Feature packages have one brand-agnostic part that contains the main code, views, business logic and api connection. For each brand, the feature package have an additional part which contain further source files to customise the build as well as resources and assets.

Depending on which scheme & target are selected, the appropriate brand parts of each feature package are included in the build. Assets, code and resources not needed are not included in the final binary. We wanted to uphold the paradigm of compiletime-safety for the modularisation of our app as good as we possibly can.

App.xcodeproj
├─ Schemes
│  ├─ App-Aurora
│  ├─ App-Nimbus
│  ├─ App-Granite
│  └─ App-Harbor
│
├─ Targets
│  ├─ App-Aurora
│  │   ├─ Configurations: Debug (local), Release (CI-only)
│  │   └─ Dependencies → Modules/... brand-matching products
│  ├─ App-Nimbus
│  ├─ App-Granite
│  └─ App-Harbor
│
└─ Modules/                (local SPM packages live here)

Modules/
├─ DesignSystem/              (SPM)
│  ├─ Products
│  │   ├─ DesignSystem
│  └─ Targets
│      ├─ DesignSystem
│      ├─ DesignSystemAurora               [swiftSettings: BRAND_AURORA]   [SwiftGen]
│      ├─ DesignSystemNimbus               [swiftSettings: BRAND_NIMBUS]   [SwiftGen]
│      ├─ DesignSystemGranite              [swiftSettings: BRAND_GRANITE]  [SwiftGen]
│      └─ DesignSystemHarbor               [swiftSettings: BRAND_HARBOR]   [SwiftGen]
│
└─ FeatureDraft/              (SPM)
   ├─ Products
   │   ├─ FeatureDraftImpl
   └─ Targets
       ├─ FeatureDraftImpl
       │    └─ deps: DesignSystem
       ├─ FeatureDraftImplAurora               [BRAND_AURORA]   [SwiftGen]
       ├─ FeatureDraftImplNimbus               [BRAND_NIMBUS]   [SwiftGen]
       ├─ FeatureDraftImplGranite              [BRAND_GRANITE]  [SwiftGen]
       └─ FeatureDraftImplHarbor               [BRAND_HARBOR]   [SwiftGen]

Further requirements

Since this is a scaled project where multiple teams (up to 15 iOS engineers) will be working alongside each other on different features, we want to reduce central code places as best as possible to avoid merge conflicts and resulting bugs.

Compiletime-safe access to assets is currently envisioned with SwiftGen, currently embedded as a plugin into each feature package. This poses one of the main challenges: The asset generation is run for a brand-specific target, but the generated files as well as the bundled assets need to be available from the agnostic target.

Furthermore, this introduces another requirement. If Brand A has asset Foo, but Brand B does not, using SwiftGen would result in a compiler error if used in the agnostic target, because Brand B's generated Asset.swift does not carry the symbol for Foo. Hence, the agnostic target should know about what brand it is being built for, ideally through swift flags.

Solutions considered

Tuist / Xcodegen

We thought about generating the Xcodeproj file using tuist. We find this approach to be not preferable because it makes configuring the Xcode project a lot harder, especially for junior developers who support and learn on the project. Also, it reduces developer productivity and ergonomy. In my personal opinion, we are not yet at the scaling point where trading productivity and ergonomy is worth the advantages of deterministic project generation that tuist offers.

Configurable/Generated Swift Packages

Another idea revolved around making the Package.swift for each feature itself configurable, so that the agnostic, shared target can dynamically depend on the selected brand. The following Package.template.swift was what I tried to run through envsubst to populate the variables in the config section.

// swift-tools-version: 6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import Foundation
import PackageDescription

// ===================================================== [ BRAND CONFIGURATION ] ======================================================

let brand = "${BRAND}"
let otherSwiftFlags = "${OTHER_SWIFT_FLAGS}"

let swiftSettings: [SwiftSettings] = {
    // convert otherSwiftFlags -D SOME_FLAG to [SwiftSettings]
}()

// ==================================================== [ PACKAGE CONFIGURATION ] ====================================================

let package = Package(
    name: "DesignSystem",
	defaultLocalization: "en",
    platforms: [.iOS(.v16)],
    products: [
        .library(
            name: "DesignSystem",
            targets: ["DesignSystem"]
        ),
    ],
    dependencies: [
        .package(url: "https://github.com/SwiftGen/SwiftGenPlugin", from: "6.6.0")
    ],
    targets: [
        .target(
            name: "DesignSystem",
            dependencies: [.target(name: "DesignSystem\(brand)")],
            swiftSettings: swiftSettings
        ),
        .target(
            name: "DesignSystemAurora",
            resources: [.process("Resources")],
            swiftSettings: swiftSettings,
            plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
        ),
        .target(
            name: "DesignSystemNimbus",
            resources: [.process("Resources")],
            swiftSettings: swiftSettings,
            plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
		),
		.target(
			name: "DesignSystemGranite",
			resources: [.process("Resources")],
			swiftSettings: swiftSettings,
			plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
		),
		.target(
			name: "DesignSystemHarbor",
			resources: [.process("Resources")],
			swiftSettings: swiftSettings,
			plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
		)
    ]
)

Since the Package.swift is compiled ahead of the actual build and cached (at least when you use Xcode), this approach proves to be rather difficult. There seems to be no reliable way to tell Xcode: "hey, I just ran this script and figured we switched to another brand. I rewrote all Package.swift files from a template, so please rebuild the swift files and then regenerate your dependency graph".

This rules out a whole bunch of options that all rely on making the Package.swift file dynamic.

SE-450: Traits

I was pretty excited when I read about SE-450 Traits because it looked like I can just give a package traits for the different brands:

// swift-tools-version: 6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import Foundation
import PackageDescription

let package = Package(
    name: "DesignSystem",
	defaultLocalization: "en",
    platforms: [.iOS(.v16)],
    products: [
        .library(
            name: "DesignSystem",
            targets: ["DesignSystem"]
        ),
    ],
    traits: [
        "BRAND_AURORA",
        "BRAND_NIMBUS",
    ],
    dependencies: [
        .package(url: "https://github.com/SwiftGen/SwiftGenPlugin", from: "6.6.0")
    ],
    targets: [
        .target(
            name: "DesignSystem",
            dependencies: [
                .target(name: "DesignSystemAurora", condition: .when(traits: ["BRAND_AURORA"])),
                .target(name: "DesignSystemNimbus", condition: .when(traits: ["BRAND_NIMBUS"])),
            ],
            swiftSettings: swiftSettings
        ),
        .target(
            name: "DesignSystemAurora",
            resources: [.process("Resources")],
            swiftSettings: swiftSettings,
            plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
        ),
        .target(
            name: "DesignSystemNimbus",
            resources: [.process("Resources")],
            swiftSettings: swiftSettings,
            plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
		),
    ]
)

Works well with swift build, does not work at all with Xcode. While in theory SE-450 allows (although discourages) mutually exclusive traits like in my case, Xcodes lack of support for proper trait configuration drives it to just enable all traits on all packages at the same time. I did try to route the build through a dummy package, which has no traits itself but enables the correct single trait on the packages it depends on. Still, Xcode enables all traits on all packages.

Brand-specific products

Another approach is to make the products themselves brand-specific. The top level target then declares dependency on the correct product-brand combination of each package. Since SPM does not allow for overlapping source sets inside our feature packages, we symlink the brand agnostic part (folder) into the brand-specific target.

// swift-tools-version: 6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import Foundation
import PackageDescription

let package = Package(
    name: "DesignSystem",
    defaultLocalization: "en",
    platforms: [.iOS(.v16)],
    products: [
        .library(
            name: "DesignSystemAurora",
            targets: ["DesignSystemAurora"]
        ),
        .library(
            name: "DesignSystemNimbus",
            targets: ["DesignSystemNimbus"]
        ),
    ],
    dependencies: [
        .package(url: "https://github.com/SwiftGen/SwiftGenPlugin", from: "6.6.0")
    ],
    targets: [
        .target(
            name: "DesignSystemAurora",
            resources: [.process("Resources")],
            swiftSettings: [.define("BRAND_AURORA")],
            plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
        ),
        .target(
            name: "DesignSystemNimbus",
            resources: [.process("Resources")],
            swiftSettings: [.define("BRAND_NIMBUS")],
            plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
        ),
    ]
)

Now other feature modules have to depend on the respective brand product, which in itself is ok. If every target knows about its brand, it may as well select the appropriate dependency products statically. However, this is an issue in the brand-agnostic parts of consuming feature modules. They should not have to conditionally import everywhere:

#if BRAND_AURORA
import DesignSystemAurora
#elseif BRAND_NIMBUS
import DesignSystemNimbus
#else
#error("No brand selected")
#endif

To make the consuming module unaware, we can use SE-339 Module Aliasing.

// swift-tools-version: 6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import Foundation
import PackageDescription

let package = Package(
    name: "FeatureDraft",
    platforms: [.iOS(.v16)],
    products: [
        .library(
            name: "FeatureDraftAurora",
            targets: ["FeatureDraftAurora"]
        ),
        .library(
            name: "FeatureDraftNimbus",
            targets: ["FeatureDraftNimbus"]
        ),
    ],
    dependencies: [
        .package(url: "https://github.com/SwiftGen/SwiftGenPlugin", from: "6.6.0"),
        .package(path: "../DesignSystem")
    ],
    targets: [
        .target(
            name: "FeatureDraftAurora",
            dependencies: [
                .product(name: "DesignSystemAurora", package: "DesignSystem", moduleAliases: ["DesignSystemAurora": "DesignSystem"])
            ],
            resources: [.process("Resources")],
            swiftSettings: [.define("BRAND_AURORA")],
            plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
        ),
        .target(
            name: "FeatureDraftNimbus",
            dependencies: [
                .product(name: "DesignSystemNimbus", package: "DesignSystem", moduleAliases: ["DesignSystemNimbus": "DesignSystem"])
            ],
            resources: [.process("Resources")],
            swiftSettings: [.define("BRAND_NIMBUS")],
            plugins: [.plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")]
        ),
    ]
)

Unfortunately I could not get this to work. In the consuming FeatureDraft targets, it would always look for DesignSystemAurora or DesignSystemNimbus, depending on the target I selected. Additionally, incorrect swift settings have been passed to some source files, where the swift compiler thought BRAND_NIMBUS to be defined even though it built FeatureDraftAurora according to the build log.

Bottom line

I feel I am out of options at this point, so I am asking here for help. I realise I am probably way down a rabbit hole and there may be easier solution alternatives than the ones I had already tried.

I'd be happy if anyone has an idea or even offers some of their time for a sparring, for example on Discord.