Tuist / SPM / XcodeProj: which one for a large organization?

A decision article for teams of 20 to 200 iOS engineers. Hand-maintained .xcodeproj, a thin project plus local Swift packages, project generation with Tuist or XcodeGen, and Bazel. What we pick in which situation, with a migration path you can reverse.

In a twenty-person iOS team, project.pbxproj is the one file nobody owns and everybody edits every day. At fifty people it breaks a few times a week, and at a hundred it has quietly become somebody's full-time job. That is when three proposals arrive at the meeting: "make everything a Swift package", "move to Tuist", "the big companies use Bazel". Each is right somewhere, and each is an expensive mistake in the wrong team.

This article compares the options as the tools stand in October 2026 and says which one we pick for which organization. The running example is a hypothetical travel app: Rota, 60 engineers, 90 modules, a two-week release train.

Where a hand-maintained .xcodeproj hurts

Merge conflicts

The classic picture: two people add a file to the same target on the same day, and both write lines into the PBXBuildFile and PBXFileReference sections.

<<<<<<< HEAD
        7A1F0B3C2D8E4A1100C3B9F1 /* FareRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1F0B3B2D8E4A1100C3B9F1 /* FareRulesView.swift */; };
=======
        7A22C4D12D8E51F400A7E6B2 /* SeatMapViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A22C4D02D8E51F400A7E6B2 /* SeatMapViewModel.swift */; };
>>>>>>> feature/seat-map

The resolution is trivial (keep both lines), but it repeats in four sections, and when one of them is resolved wrong the build still passes. The bug shows up a week later as "the file is in the project but not in the target".

Apple has fixed most of this. Buildable folders, introduced in Xcode 16 (PBXFileSystemSynchronizedRootGroup inside the pbxproj), record only the folder path in the project file without enumerating the files inside. The release notes state the goal plainly: minimize project file changes and avoid version control conflicts. You convert an existing group with "Convert to Folder" in the Project navigator. Do this before adopting any tool; in most teams it makes file-related conflicts nearly disappear.

What buildable folders do not touch still lives in the pbxproj: target definitions, build phases, target dependencies, package references and, above all, build settings.

Build settings that drift between targets

A pattern we see often in large codebases: 40 framework targets, all copied from the same template, none of them alike three years later. It is easy to measure:

# Print the settings we care about for every target's Debug configuration
for target in $(xcodebuild -list -json -project Rota.xcodeproj | jq -r '.project.targets[]'); do
  xcodebuild -showBuildSettings -json -project Rota.xcodeproj \
      -target "$target" -configuration Debug 2>/dev/null \
    | jq -r --arg t "$target" '.[0].buildSettings
        | [$t, .SWIFT_VERSION, .IPHONEOS_DEPLOYMENT_TARGET,
           .SWIFT_STRICT_CONCURRENCY, .ENABLE_TESTABILITY] | @tsv'
done | column -t

In a project like Rota the output tends to look like this:

BookingFeature   6.0  17.0  complete  YES
SearchFeature    5.0  16.0  minimal   YES
PaymentsKit      5.0  17.0            NO
DesignSystem     6.0  15.0  targeted  YES

Nobody remembers why testability is off for PaymentsKit, because a pbxproj takes no comments and git blame points at a commit titled "Update to recommended settings". The reason left the company two years ago in someone's head.

Onboarding

A new engineer wants to create a module. In a hand-maintained project that is a 14-step wiki page last updated two Xcode versions ago. The outcome is one more target that deviates from the template.

What recent Xcode versions changed

Three changes belong in the decision. Buildable folders (Xcode 16) cut file conflicts. Explicitly built modules arrived in Xcode 16 and became the default for Swift targets in Xcode 26. Xcode 26 also introduced an opt-in compilation cache: the "Enable Compilation Caching" setting serves prior results when the same inputs are compiled again, and Apple's notes say it helps most when switching branches and on clean builds. That last one matters, because the sentence "if we want binary caching we have to move to project generation" is less true than it used to be.

Option 1: a thin project plus local Swift packages

The app project holds only the app target, the extensions, entitlements and signing. Everything else lives in local packages. This is also the route Apple's own documentation recommends.

// swift-tools-version: 6.2
// Packages/Features/Package.swift
import PackageDescription

let package = Package(
    name: "Features",
    defaultLocalization: "en",
    platforms: [.iOS(.v17)],
    products: [
        .library(name: "BookingFeature", targets: ["BookingFeature"]),
        .library(name: "SearchFeature", targets: ["SearchFeature"]),
    ],
    dependencies: [
        .package(path: "../Core"),
    ],
    targets: [
        .target(
            name: "BookingFeature",
            dependencies: [
                .product(name: "Networking", package: "Core"),
                .product(name: "DesignSystem", package: "Core"),
            ],
            resources: [.process("Resources")],
            swiftSettings: [.swiftLanguageMode(.v6)]
        ),
        .testTarget(name: "BookingFeatureTests", dependencies: ["BookingFeature"]),
        .target(name: "SearchFeature", dependencies: ["BookingFeature"]),
    ]
)

The strengths are obvious. The manifest is Swift, takes comments and goes through code review. Adding a file changes no manifest. The tool is Apple's and works on day one of every Xcode beta. You can validate without Xcode using swift build and swift test. And you depend on no third-party tool.

The limits show up as the module count grows.

Build settings control is narrow. A package target accepts swiftSettings, cSettings and linkerSettings; it cannot be given an xcconfig. A target that uses unsafeFlags makes its products, in the documentation's words, ineligible for use by other packages (try how that behaves for local path dependencies in your own toolchain version). There are no run script phases; you write a build tool plugin. In most cases Xcode decides whether a product links statically or dynamically.

Resources come through Bundle.module. That means a different bundle lookup depending on whether the code is compiled in a package or in a framework target, and it is one of the places you will have to touch if you later move to another setup.

Scheme and indexing behaviour gets harder with large packages. Xcode auto-creates a scheme for every package product, and with 90 modules the scheme list stops being usable. Package resolution runs when the project opens and when you switch branches. Keep 90 targets in one giant Package.swift and the manifest becomes the new conflict hotspot; split into 30 packages and you repeat platform and Swift settings in each Package.swift. We covered that trade-off in detail in SPM with 100 modules.

Graph tooling is limited. swift package show-dependencies works at package level only. For the target graph you process the output of swift package describe --type json yourself. There is no ready answer to "which modules does this PR affect, run only their tests"; you write it.

Even so, for single-app teams between 20 and 60 engineers this is usually our first choice.

Option 2: generating the project

The idea behind project generation is simple: the .xcodeproj is a build artifact and stays out of git. The source of truth is a readable manifest.

Tuist

Tuist manifests are Swift (Project.swift, Workspace.swift, Tuist.swift), and shared code under Tuist/ProjectDescriptionHelpers is compiled into a module the manifests import. That is exactly where a platform team's rules live:

// Tuist/ProjectDescriptionHelpers/Target+Feature.swift
import ProjectDescription

extension Target {
    /// Every feature module in Rota goes through this function.
    /// Settings come from one xcconfig, and the reasons are written there as comments.
    public static func feature(
        _ name: String,
        dependencies: [TargetDependency] = []
    ) -> [Target] {
        let settings: Settings = .settings(configurations: [
            .debug(name: "Debug", xcconfig: "Config/Module.xcconfig"),
            .release(name: "Release", xcconfig: "Config/Module.xcconfig"),
        ])
        return [
            .target(
                name: name,
                destinations: .iOS,
                product: .staticFramework,
                bundleId: "com.example.rota.\(name)",
                deploymentTargets: .iOS("17.0"),
                buildableFolders: [.folder("Modules/\(name)/Sources")],
                dependencies: dependencies,
                settings: settings
            ),
            .target(
                name: "\(name)Tests",
                destinations: .iOS,
                product: .unitTests,
                bundleId: "com.example.rota.\(name)Tests",
                deploymentTargets: .iOS("17.0"),
                buildableFolders: [.folder("Modules/\(name)/Tests")],
                dependencies: [.target(name: name)],
                settings: settings
            ),
        ]
    }
}
// Project.swift
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project(
    name: "Rota",
    targets: [
        .target(
            name: "Rota",
            destinations: .iOS,
            product: .app,
            bundleId: "com.example.rota",
            deploymentTargets: .iOS("17.0"),
            infoPlist: "App/Info.plist",
            buildableFolders: [.folder("App/Sources"), .folder("App/Resources")],
            dependencies: [
                .target(name: "BookingFeature"),
                .target(name: "SearchFeature"),
            ]
        ),
    ]
    + Target.feature("BookingFeature", dependencies: [
        .target(name: "Networking"),
        .external(name: "Alamofire"),   // declared in Tuist/Package.swift
    ])
    + Target.feature("SearchFeature", dependencies: [.target(name: "BookingFeature")])
    + Target.feature("Networking")
)

The daily flow is tuist install (resolves external packages) and tuist generate. Because the targets use buildableFolders, adding a file needs no regeneration. You regenerate when a manifest changes.

What you get in return:

  • Graph commands. tuist graph exports the graph as dot, json, png or svg, and tuist inspect dependencies --only implicit finds undeclared imports through static analysis and exits non-zero when it finds any. That is the command to put in CI. (The older enforceExplicitDependencies option has been deprecated in its favour.) How to read the graph is the subject of our dependency graph article.
  • Module cache. tuist cache hashes targets, builds them as .xcframeworks, and when you run tuist generate BookingFeature it swaps the modules you are not working on for binaries. The documentation is explicit that this is for development workflows and that release builds should be generated from source (--cache-profile none).
  • Selective testing. tuist test uses the same hashes to skip test targets unaffected since the last successful run. Today it is supported for generated projects only.
  • Xcode cache. It connects Xcode 26's compilation cache to a remote store. This needs no project generation and works with any Xcode project.

What is open source and what is paid will come up in the meeting, so let us be precise. The CLI and project generation are MIT licensed and need no account. The server's source sits in the same repository but is not MIT (Fair Core License, so source-available). Hosting it on your own infrastructure requires, per the documentation, a paid license on the Enterprise plan. On the hosted service, at the time of writing, generated projects, selective testing and build insights are listed as unlimited on every plan. The usage-billed items are data downloaded from the cache (egress), cache request count and test insights. We are not quoting figures because they change; read the pricing page before deciding and estimate from one week of your own CI traffic.

There is a cost as well. Tuist ships weekly, and its server supports only CLI releases from the last three months, so if you use the cache you must update the CLI at least once a quarter. It turns external packages into targets through its own XcodeProj-based integration. That is what makes the cache work, but support for new SwiftPM features can land later than in Xcode's built-in integration (Tuist's own documentation says so). And since the manifest is Swift, a sufficiently creative platform team can build a DSL inside the helpers that nobody else can read: the project-definition edition of the 400-line AppContainer.

XcodeGen

XcodeGen is the plain version of the same idea: one YAML spec, an MIT-licensed CLI, no server, no account.

# project.yml
name: Rota
options:
  bundleIdPrefix: com.example.rota
  deploymentTarget:
    iOS: "17.0"
  defaultSourceDirectoryType: syncedFolder   # Xcode 16 buildable folders
configFiles:
  Debug: Config/Project.xcconfig
  Release: Config/Project.xcconfig
packages:
  Core:
    path: Packages/Core
settingGroups:
  module:
    SWIFT_VERSION: "6.0"
    SWIFT_STRICT_CONCURRENCY: complete
targetTemplates:
  Feature:
    type: framework.static
    platform: iOS
    sources: [Modules/${target_name}/Sources]
    settings:
      groups: [module]
targets:
  Rota:
    type: application
    platform: iOS
    sources: [App]
    dependencies:
      - target: BookingFeature
      - target: SearchFeature
  BookingFeature:
    templates: [Feature]
    dependencies:
      - package: Core
        product: Networking
  SearchFeature:
    templates: [Feature]
    dependencies:
      - target: BookingFeature

xcodegen generate produces the project. settingGroups and targetTemplates end settings drift, syncedFolder is supported, and include lets you split the spec across files. What it lacks: no binary cache (the --use-cache flag only skips regeneration when the spec has not changed), no selective testing, no graph command. YAML has no loops or conditionals, so the spec grows long at 90 modules. At the time of writing the latest release is 2.46.0 (July 2026), and it is a community project that updates less often than Tuist.

For us XcodeGen has a clear place: you want to fix conflicts and consistency, you do not need a cache, and you want to be able to walk away in two hours.

Option 3: leaving Xcode's build system

Bazel (with rules_apple, rules_swift and rules_xcodeproj, which generates the Xcode project) gives you hermetic builds, a remote cache and remote execution. rules_xcodeproj lists support for indexing, debugging and Xcode Previews and can generate focused projects. Who should move: organizations where Android, backend and iOS share one monorepo, that already have a Bazel team, and that can assign at least a few engineers to the iOS build full time. Who should not: anyone whose reason is "the build is slow". Once on Bazel you wait for rule sets to catch up with every Xcode release, you write the integration for every third-party SDK yourself, and you teach Starlark to every iOS engineer you hire. Below 200 engineers, for iOS alone, we do not recommend it. Xcode 26's compilation cache and Tuist's module cache deliver most of what those teams expect from Bazel at a far lower maintenance cost.

The criteria that decide it

Module count comes first. Below 30 targets no tool pays for itself. Between 30 and 100, local packages carry the load comfortably. Above 100, focused project generation (source for the modules you work on, binaries for the rest) makes a noticeable difference to how long Xcode takes to open and index. Whether the module count itself is right is a separate question, which we argued in the cost of modularization.

CI minutes come second, and they are measurable. Take the total minutes spent in PR pipelines over the last 30 days and work out how much of that was rebuilding and retesting modules that had not changed. If the share is low, a cache gains you nothing. If it is high, first switch on Xcode 26's compilation cache in your existing project and measure; if that is not enough, look at module cache and selective testing.

Who maintains the build tooling is the third. Without at least two named people (one of them will go on holiday), do not move to project generation. For Bazel that number is a team.

For teams on a release train there is one rule: the release is built without the cache, from a project generated from source, with a pinned tool version. Pin the Tuist or XcodeGen version in the repository and do not upgrade tooling on a release branch.

For previews and developer experience, local packages hold the fewest surprises. With generated projects that use buildable folders the daily flow is nearly the same; the difference is running generate after switching branches. You cannot step through a module that has been replaced by a binary; you put it in focus and regenerate.

The last criterion is third-party risk. If your project definition depends on someone else's tool, you should know the way out. For both Tuist and XcodeGen it is the same: commit the last generated .xcodeproj to git and you are back on a hand-maintained project. Because Tuist's CLI is MIT licensed, generation keeps working whatever happens to the company. The real dependency forms around the cache and selective testing: once your CI times are shaped by them, losing the service makes your pipeline slower, though it does not stop it.

If the project file is a generated artifact, giving it up is a commit. Giving up a build system is a project.

Our verdict

Situation Our pick Verdict
Fewer than 20 people, fewer than 30 targets, one app Plain Xcode project, buildable folders, xcconfig Add no tool
20 to 60 engineers, 30 to 100 modules, one app Thin project plus local packages First choice
Same scale, but many apps, extensions and white-label targets XcodeGen Packages do not solve app targets, a spec does
60 to 200 engineers, 100+ modules, the CI queue is a standing complaint Tuist generation, module cache, selective testing Yes, if a platform team exists
Same scale, no platform team Local packages and Xcode compilation cache Find an owner first, then a tool
Build artifacts may not leave the company Tuist generation (no account) with local cache, or Enterprise self-hosting Cost it together with legal
Multi-language monorepo, existing Bazel team, 200+ engineers Bazel and rules_xcodeproj Only when all of these hold

There are cases where we would stay on a plain Xcode project. If pbxproj conflicts drop to a handful a month after converting groups to buildable folders and moving settings into xcconfig files, the problem is solved. If nobody will look after build tooling, a new tool only adds another ownerless file. If you will hand the project over to another team or a client, you are also handing over a tool they do not know. And if you must try every Xcode beta on day one (teams building a platform SDK, for instance), every layer between you and Xcode is a day of delay.

A migration you can reverse

The migration has one rule: the old project stays until the new one has run in one team for two weeks without trouble.

Step one is independent of any tool and worth doing on its own: move build settings out of the pbxproj into xcconfig files. Tuist provides tuist migration settings-to-xcconfig for this, and it can be done by hand. The result is that the reason for a setting can finally be written down:

// Config/Module.xcconfig
SWIFT_VERSION = 6.0
IPHONEOS_DEPLOYMENT_TARGET = 17.0
SWIFT_STRICT_CONCURRENCY = complete

// Was off in PaymentsKit: an old version of the 3DS SDK failed to link with testability.
// The SDK is on 4.x now and no longer needs it. If you turn it off again, write why here.
ENABLE_TESTABILITY[config=Debug] = YES

In step two, generate the new project next to the old one. Tuist's own migration guide suggests a -Tuist suffix on the project name to avoid clashes. The existing Rota.xcodeproj stays in git; Rota-Tuist.xcodeproj is in .gitignore.

In step three, compare the build settings of the two projects, and do it with a diff rather than by eye:

#!/bin/bash
# compare-settings.sh <target> <configuration>
set -euo pipefail

dump() {
  xcodebuild -showBuildSettings -json \
      -project "$1" -target "$2" -configuration "$3" 2>/dev/null \
    | jq -S '.[0].buildSettings
        | with_entries(select(.key
            | test("DIR$|_PATH$|ROOT$|^PROJECT_|^BUILD_|^TARGET_BUILD") | not))'
}

diff <(dump Rota.xcodeproj "$1" "$2") <(dump Rota-Tuist.xcodeproj "$1" "$2") \
  && echo "$1 ($2): identical"

We filter out keys that contain paths because the project names differ. Every remaining difference is either a deliberate fix (it goes in the PR description) or a bug. Run this script in CI for every target and every configuration.

In step four, CI builds and tests both projects. That raises CI time for the duration of the migration; accept it knowing it is temporary.

In step five, one team switches to the new project. Pick the team that adds the most modules, since they will find the problems fastest. For two weeks, watch these: do previews work, does a debug session attach, does the archive produce the same binary size and the same entitlements as the old project, and has that team's count of pbxproj conflicts dropped to zero.

In step six, everyone switches and the old .xcodeproj is deleted from git. If you ever need to go back, you commit the generated project and you are done. Turn on the cache and selective testing after this point, as a separate decision. If you roll out both at once and something breaks, you will not be able to tell which one caused it.

Checklist for the decision meeting

  • Groups have been converted to buildable folders and the pbxproj conflict count for the last 30 days has been measured again.
  • Build settings differences across targets have been dumped, and the settings nobody can explain have been counted.
  • The module count today and the expected count a year from now are written down.
  • PR CI minutes for the last 30 days, and the share spent on unchanged modules, have been measured.
  • Xcode 26 compilation cache has been tried on the existing project and the result recorded.
  • At least two people who will own build tooling are named.
  • The chosen tool's license, its paid items and where build artifacts go have been discussed with legal and security.
  • How the tool version is pinned, and how it is handled on a release branch, is decided.
  • The exit plan is written: how many hours it takes to commit the generated project and return to hand maintenance.
  • The pilot team, the success criteria for the two-week pilot and the date the old project is deleted are set.
Level 14 completeYour progress is kept in this browser.
Up next: A 100+ module iOS architecture on Swift Package Manager

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.

Comments and questions

Disagree with something, or stuck on this in your own project? Write to us and let's talk.