Why must the module dependency graph be acyclic, how do you layer it, and what does its shape do to build times?
Predict first
Two targets depend on each other. What does the package manager report?
// === Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "Cyc", targets: [.target(name: "A", dependencies: ["B"]), .target(name: "B", dependencies: ["A"])])
// === Sources/A/A.swift ===
public let a = 1
// === Sources/B/B.swift ===
public let b = 1
- A warning, and it builds
- An error: cyclic dependency declaration found: A -> B -> A
- It builds B first, then A
Mental model. A module graph is a recipe where every ingredient must be prepared before the dish that uses it. If dish A needs sauce B and sauce B needs dish A, nothing can start. A long chain of dependent steps takes longest no matter how many cooks you have.
A module must be compiled after everything it imports, so dependencies must form a directed acyclic graph. If A depends on B and B on A, neither can be built first, and SwiftPM stops with “cyclic dependency declaration found: A -> B -> A”. Cycles usually appear when two features need each other or a “shared” module starts importing the things that use it. The fix is never “merge them” by reflex, but to break the cycle with an interface: extract the types both need into a lower module (the protocols, models or a small FeatureAInterface target), depend on that from both sides, and let the app wire the concrete implementations together (dependency inversion, as in the architecture topic). A healthy graph is layered: foundation modules at the bottom (logging, utilities, design tokens), domain and interface modules above, feature modules above those depending downward only (and not on each other), and the app target on top as the composition root. Enforce the rules in the build (no import is possible without a declared dependency), and check them in CI with a graph script or tool.
The shape of the graph decides build speed. The build system compiles independent modules in parallel but a module waits for all its dependencies, so the critical path (the longest chain of dependent builds) bounds the minimum clean build time however many cores you have: a deep tower of ten sequential modules is slower than a wide, shallow graph of the same size. Hubs (a “Core” module that everything imports) make every change to them rebuild the world, so keep hub modules small and stable, split a big “Utils”, and put volatile code in leaves. Fan-in (how many modules depend on you) measures the blast radius of a change; fan-out (how many you depend on) measures how fragile you are. Target many small cohesive modules up to the point where overhead dominates: each module adds interface generation and linking work. Useful checks: print the graph (swift package show-dependencies, a describe script, or Xcode’s build timeline), find the longest path, list modules with high fan-in that change often, and compute the affected set of a change (itself plus everything that depends on it, directly or not) to know what must be rebuilt and retested.
A module graph must be acyclic; a cycle means no valid build order exists.
Common traps
- Breaking a cycle by merging the two modules. You lose the boundary; extract the shared interface into a lower module instead.
- One huge “Core” or “Utils” module that everything imports. Any edit rebuilds everything; split it and keep hubs stable.
They might follow up with
- What limits clean build time in a modular app? The critical path: the longest chain of modules that must build one after another.
- How do you find what a change affects? The reverse-dependency closure of the changed module.
Related questions
- 16.3.1 How do static and dynamic linking differ for an app, what do they cost at launch and in size, and what are…
- 16.4.2 How do you speed up and diagnose slow builds: incremental builds, whole-module optimisation, explicit modules…
- 15.3.1 What is a feature module, how do you draw module boundaries, and how does access control enforce them?
← How do static and dynamic linking differ for an app, what do they…