A CI pipeline takes hours on a large modular app. How do you analyse and cut it: affected builds, caching, parallelism, and Tuist or Bazel?

16.6.1 · L8 · Interview

Predict first

Given a module graph, which modules are affected by a change to Networking?

let dependencies: [String: [String]] = [
    "App": ["Checkout", "Profile"],
    "Checkout": ["Networking", "UI"],
    "Profile": ["Networking", "UI"],
    "Networking": ["Core"],
    "UI": ["Core"],
    "Core": []
]
func affected(by changed: String) -> [String] {
    var result: Set<String> = [changed]
    var grew = true
    while grew {
        grew = false
        for (module, deps) in dependencies where !result.contains(module) && deps.contains(where: result.contains) {
            result.insert(module)
            grew = true
        }
    }
    return result.sorted()
}
print(affected(by: "Networking"))
  • ["App", "Checkout", "Networking", "Profile"]
  • ["Networking"]
  • ["App", "Checkout", "Core", "Networking", "Profile", "UI"]

Mental model. A slow pipeline is a factory that rebuilds the whole car for every changed bolt. The fix is to know exactly which parts the bolt touches, reuse parts already made and tested, and run independent stations side by side.

Start by describing the pipeline as data: stages (resolve dependencies, build, unit tests, UI tests, lint, archive, upload), their durations, what each depends on and how often each fails; then target the longest or most wasteful. The standard strategies. 1) Build and test only what a change affects. From the module graph compute the affected set: the changed modules plus every module that depends on them, directly or transitively (the reverse-dependency closure). A change to a leaf feature affects the feature and the app; a change to a foundation module affects nearly everything. Run the unit tests of the affected modules only on pull requests, and the full suite on merges to main or nightly. Changed files map to modules through the directory layout (a path-to-target table or a script over swift package describe). 2) Cache. Cache dependency resolution (checkout of packages), derived data or build products keyed by the content of the sources and settings, and, with a content-addressed remote cache (Xcode’s compilation cache, Bazel, Tuist), reuse compiled outputs from any machine so unchanged modules are never recompiled. Cache correctness depends on hermetic inputs: pin the toolchain, dependency versions and environment, or cache hits are wrong or rare. 3) Parallelise: split tests into shards across machines (by module or by measured duration), run lint and unit tests alongside the build, and avoid a serial chain of stages. 4) Make tests fast and reliable: separate fast unit tests (run everywhere, on a macOS or simulator-less host where possible) from slow UI tests (run less often, retried carefully), quarantine flaky tests, since a flaky pipeline costs more than a slow one, and fail fast on cheap checks.

At large scale the build system itself matters. Tuist generates Xcode projects from Swift manifests (avoiding merge conflicts in the project file, enforcing a module graph and templates) and offers caching of module binaries; Bazel (with rules_swift and rules_apple) is a hermetic, fully declarative build system with fine-grained dependency tracking, remote execution and caching, which gives the best scaling for very large repositories at the cost of a steep learning curve, a parallel build description to maintain, and weaker Xcode integration; SwiftPM and plain Xcode projects suffice for most apps. Treat them as awareness, not a goal: adopt one only when measured build and CI times justify the switch. Operate it: track build-time and flakiness metrics over time, set budgets (clean build, incremental build, PR time), alert on regressions, keep the graph healthy (small stable hubs, no cycles), and own the pipeline like a product with a team and an on-call. A fast feedback loop is a feature: slow CI quietly slows every engineer.

The change affects Networking and everything that depends on it, directly or indirectly: Checkout and Profile, and through them the App. Core, UI and other lower modules are untouched.

What it givesCost
SwiftPM + Xcodebuilt-in, simple, good defaultslimited caching and graph tooling at very large scale
Tuistgenerated projects, module graph rules, binary cacheanother tool and manifest to maintain
Bazelhermetic builds, remote cache and execution, fine-grained graphsteep learning curve, parallel build description, weaker Xcode integration

Common traps

  • Running the full test suite on every pull request. Compute the affected modules and run only their tests there; run everything on main.
  • A cache with non-hermetic inputs. Unpinned toolchains or dependencies make hits wrong or rare; pin everything the output depends on.

They might follow up with

  • How do you find what a change affects? The reverse-dependency closure of the changed modules in the graph.
  • When is Bazel worth it? For very large repositories where measured build times justify a hermetic build system and its cost.

Related questions

  • 16.3.2 Why must the module dependency graph be acyclic, how do you layer it, and what does its shape do to build…
  • 16.4.2 How do you speed up and diagnose slow builds: incremental builds, whole-module optimisation, explicit modules…
  • 16.5.2 How do you use feature flags: compile-time versus runtime, ownership, and modularisation by team?