A pattern we see often in large codebases: the team spent a year splitting the app into 60 modules, there is a "modular architecture" slide in the deck, and adding a case to an enum in Core still sends everyone for coffee. The module count went up and incremental build time stayed where it was. What decides the time is where the arrows between the boxes point, and those arrows point at the same place they did before the split.
This article is about those arrows: what the compile-time graph is in a Swift project, how Swift decides what to rebuild after a change, and how to shape the graph so that the code you touch most often triggers the least work. The Android side is covered in a separate article on Gradle; the idea is the same, the mechanism is different.
The shape of the graph decides the time
The nodes of the compile-time graph are targets and the edges are import statements. The dependencies: list in Package.swift, or the target dependencies in Xcode, say what may be imported; the real edge is drawn by the import in a source file. Three properties of that graph decide incremental build time.
The first is depth. In a chain like App → Booking → Search → Payments → Networking → Core, every link waits for the module of the one before it. Apple's own documentation says as much: when a target has many dependencies, or depends on large monolithic modules, Xcode has to serialize more tasks. You may have eight cores; the chain walks in single file.
The second is fan-in: how many modules import a given module. Picture a hypothetical travel booking app whose Core module holds Money, date formatters, the network client, analytics events, feature flags and the colour palette. Every feature writes import Core.
App
┌─────────┼──────────┬──────────┐
Booking Search Payments Profile ... (40 features)
└─────────┴────┬─────┴──────────┘
Core ← fan-in: 41
┌──────────┬───┴──────┬─────────────┐
Alamofire Kingfisher FirebaseAnalytics ...
The third is where in the graph change happens. If a module with a fan-in of 41 is touched ten times a day, everything above it is invalidated ten times a day. When adding an analytics event name to Core recompiles the payment screen, the module count is irrelevant: every arrow points at the same box.
The cost of a module is how often it changes multiplied by how many modules import it.
How Swift decides what to rebuild
There are two levels, and mixing them up leads to wrong expectations.
Inside a module Swift works per file. The compiler's own documentation describes a "provides / depends" system: each file records the names it provides and the names it uses, and when a file changes only the files depending on what it provides are rebuilt. The golden rule is to be conservative: when in doubt, rebuild.
Across modules the picture is coarser. The same document says that imported Swift module files are tracked as external dependencies, and because every file in a module shares the complete import list, when an external dependency changes everything in the module is rebuilt. So if the compiled module of Core has changed, every module that imports Core is a candidate for a full recompile. Whether Booking actually uses the new function in Core makes no difference.
Which leaves the real question: what changes the compiled module? Rather than guess, we tried it. In a small five-target test package (Swift 6.4 toolchain, debug build, swift build -v) we made different kinds of edits to Core and watched what got compiled:
Change made in Core |
What was recompiled |
|---|---|
| A function body | Only that file in Core |
| A comment | Only that file in Core |
A new public function |
Core, plus files in the modules that import it |
A changed public signature |
Same |
A new internal function |
Same |
A new private function |
Same |
The last two rows surprise most people. In a debug build the compiled .swiftmodule does not behave like a summary of the public API; adding even a private declaration triggered the modules above. Body changes stayed inside the module boundary. This table is the result of one experiment and is no language guarantee; it can vary with toolchain version, optimisation settings and whether library evolution is enabled. With the method in the measuring section below you can repeat it in your own project in five minutes, and you should.
The practical consequence: "I did not touch the public API, so nobody is affected" is not a safe assumption. In a widely imported module a new declaration, whatever its access level, can recompile everything above it. The only change that is reliably cheap is a body change.
What explicitly built modules change
With Xcode 16 the build system started surfacing the compilation of module dependencies as separate tasks in the build log; according to the Xcode 26 release notes, explicit modules are now the default mode for all Swift targets. The exceptions are in the same notes: targets using a language version before Swift 5 and targets using Swift/C++ interoperability do not get it by default. The setting is SWIFT_ENABLE_EXPLICIT_MODULES.
In this mode compilation is split into three stages: dependency scanning (the compiler works out which modules the sources depend on), then building the modules in dependency order with unrelated ones in parallel, and finally compiling the source files. For graph management this has two consequences.
One is visibility. Work that used to happen implicitly inside the compiler now sits in the build log and the build timeline, line by line. Whatever your graph is, you can see it there.
The other is module variants. Apple's documentation is explicit: modules are sensitive to the compiler options of their dependents. Two targets that build the same dependencies with different Preprocessor Macros values cannot share the built module, so the same module is built twice. The Build With Timing Summary report lists which modules were built in how many variants, with the option differences. Feeding the build settings of all your targets from a single .xcconfig, or a single SwiftPM swiftSettings definition, pays off directly here.
Explicit modules do not change the invalidation rule above. When a widely imported module changes, everything above it still rebuilds; the build system just schedules that work better and shows it more legibly.
Keep dependencies out of your public interface
An edge in the graph becomes permanent the moment you put the imported module's types into your own public API. If a public function of your Networking module returns Alamofire.DataRequest, every feature using Networking in effect depends on Alamofire as well, and replacing that library is now a refactoring project.
Access levels on import (SE-0409)
SE-0409 added access-level modifiers to import declarations and shipped in Swift 6.0. Once you write internal import Alamofire, the compiler rejects any Alamofire type that appears in one of your public signatures.
// Networking/HTTPClient.swift
internal import Alamofire // implementation detail
public import Models // Models types appear in public signatures
public struct HTTPClient {
private let session: Session // Alamofire.Session, invisible from outside
public init() { self.session = Session() }
public func offers(for query: SearchQuery) async throws -> [FlightOffer] {
try await session
.request("https://api.example.com/offers", parameters: query.parameters)
.serializingDecodable([FlightOffer].self)
.value
}
// public func raw() -> DataRequest { ... }
// error: cannot be declared public, its result type comes from an internally imported module
}
A lot of wrong information circulates about the default, so let us be precise. According to the proposal, in existing language modes including Swift 6 a bare import is still treated as public; that was a deliberate choice for source compatibility. The internal default was left to a future language mode, and today you turn it on with the InternalImportsByDefault upcoming feature flag. Moving to Swift 6 mode does not give it to you.
// Package.swift: use the same settings in every target so they do not produce variants
let strict: [SwiftSetting] = [
.enableUpcomingFeature("InternalImportsByDefault"),
]
.target(name: "Networking", dependencies: ["Models", "Alamofire"], swiftSettings: strict)
For Xcode targets the equivalent is the SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT build setting. With the flag on, the compiler demands a public import for every module you use in a public signature. That list of public import lines is the inventory of what your module really carries to the outside, and it should be short enough to read in code review.
One point where honesty is needed. You will find articles saying internal import reduces build time by itself. The proposal promises that only for modules built with library evolution (resilient modules): there, indirect clients do not need to load the hidden dependency. Almost every in-app module is non-resilient, and in the proposal's words the change in that case is in type-checking only; indirect clients still load the module. In our test package there was no difference in rebuild scope between import and internal import either. We do not turn this feature on for build speed. We turn it on because it converts a third-party type leaking into public API into a compile error, which keeps the library you wrapped actually wrapped.
The package access level (SE-0386)
package, which arrived in Swift 5.9, fills the gap between internal and public: modules in the same package can see the declaration, anything outside cannot. The compiler learns which modules belong together from the -package-name flag; SwiftPM passes it for you, and for Xcode targets you set it with the SWIFT_PACKAGE_NAME build setting.
// Models/FlightOffer.swift
public struct FlightOffer: Sendable, Decodable {
public let id: String
public let price: Money
// Used by features and the test-support target in the same package; never leaves the package
package init(id: String, price: Money) {
self.id = id
self.price = price
}
}
The contribution of package to the graph is indirect but real: it stops the "fine, make it public" reflex for helper code you want to share between two modules. As long as the public surface stays small, it is clear what a module actually promises, and the interface modules in the next step can be kept small.
Interface modules are a build tool
Most teams introduce interface modules in the name of clean architecture. We introduce them for the build. Recall the table above: even a private function added to a module can recompile its importers. Someone working on the Search feature does exactly that all day: new views, new helper types, new private methods. If Booking imports Search directly, each of those changes triggers Booking too.
The fix is to reduce what Booking depends on to a small module that rarely changes:
// SearchInterface/SearchRouting.swift (the whole module is two files)
public import Models
public struct SearchRequest: Sendable {
public let origin: String
public let destination: String
public init(origin: String, destination: String) {
self.origin = origin
self.destination = destination
}
}
public protocol SearchRouting: Sendable {
func cheapestOffer(for request: SearchRequest) async throws -> FlightOffer?
}
// Package.swift (relevant part)
.target(name: "SearchInterface", dependencies: ["Models"], swiftSettings: strict),
.target(name: "Search",
dependencies: ["SearchInterface", "Models", "Networking", "DesignSystem"],
swiftSettings: strict),
.target(name: "Booking",
dependencies: ["SearchInterface", "Models", "DesignSystem"], // no Search
swiftSettings: strict),
The only place that connects the Search implementation to SearchRouting is the composition root in the app target. From then on, whatever changes inside Search, Booking is not recompiled; what is recompiled is Search and the one place that knows about it, the app target.
Two warnings. If you start putting models, extensions and helper functions into the interface module because "we will need them anyway", you soon have a second, frequently changing Core. It should contain protocols and the few value types that appear in their signatures, and nothing else. And do not open an interface module for every feature by reflex. A feature that no other feature uses does not need one; target count has a cost of its own, which we worked through in the cost of modularization.
The hygiene rules we apply
Features never import features. If a feature needs another feature, it depends on that feature's interface module. This rule cuts both cycles and deep chains at the source. A wiki page stating the rule is not enough; it has to be checked in CI, and we covered the ways to do that in enforcing architecture rules.
The graph stays shallow. In our projects the longest path from the app target to the lowest module does not exceed four layers: app, feature, interface or service, foundation modules. When you find yourself needing a fifth, a service module has usually started depending on the implementation of another service module.
The modules at the bottom are the ones that rarely change. For anything you are about to put into a high fan-in module, ask one question: how many times a month does this file change? Value types like Money and FlightOffer can live at the bottom. The analytics event list, feature flag keys and string constants, which change every sprint, cannot; they go to the feature that owns them, or at most into a small module of their own.
No Common that grows without limit. Common, Core, Shared, Utils: the name varies, the fate does not. The module that collects "I did not know where to put this" code becomes, by definition, both the most imported and the most frequently changed module. The name of a new foundation module should say what it does (Models, Networking, DesignSystem), and code outside that job should not be able to get in.
Test-support code lives in its own target. If mocks, fixtures and test builders live inside the production module behind #if DEBUG, adding a field to a mock changes the production module and recompiles everything above it. A separate target such as SearchInterfaceTestSupport is imported only by test targets and never enters the app's graph.
Seeing the graph
The graph you drew on the whiteboard and the graph the compiler sees are rarely the same. There are three places to look.
On the SwiftPM side, swift package show-dependencies --format dot prints the resolved graph in Graphviz format (the other formats are text, json and flatlist). But note: this command shows the graph between packages. If all your modules are targets in a single package, the output is empty. For the target-level graph we use swift package describe --type json; each target has a target_dependencies field, and turning that into dot takes twenty lines:
#!/usr/bin/env python3
# usage: swift package describe --type json | ./graph.py > targets.dot
import json, sys
package = json.load(sys.stdin)
fan_in = {}
print("digraph targets {")
print(" rankdir=BT;")
for target in package["targets"]:
if target["type"] == "test":
continue
for dep in target.get("target_dependencies", []):
print(f' "{target["name"]}" -> "{dep}";')
fan_in[dep] = fan_in.get(dep, 0) + 1
print("}")
# fan-in ranking goes to stderr: the top entry is your most expensive module
for name, count in sorted(fan_in.items(), key=lambda item: -item[1]):
print(f"{count:3d} {name}", file=sys.stderr)
dot -Tsvg targets.dot -o targets.svg gives you the picture, but the useful part is the fan-in list on stderr. Its first three lines are the modules you will be dealing with for the rest of this article. In the toolchain we used, describe also accepts --type mermaid; check swift package describe --help in your version. The script only sees the edges declared in the manifest; dependencies that are declared and never imported also sit in the graph and affect build order, and weeding those out is a separate clean-up job.
On the Xcode side, Product > Perform Action > Build With Timing Summary gives a breakdown of time by task type at the end of the build; the command-line equivalent is xcodebuild -showBuildTimingSummary. With explicit modules on, the same report also lists module variants. The build timeline, opened from the build log, is the graph spread over time: tasks running at the same moment are stacked in rows. The places where the timeline narrows to a single row give away the deep chain in your graph, or the one module everybody is waiting for; whatever is compiling at that moment is your bottleneck.
We are deliberately not recommending third-party graph tools here: we did not want to suggest ones whose current documentation we could not verify while writing this. The script above and Xcode's own reports give you enough data to decide.
Measuring: the gap between a leaf and the core
Nobody can plan a refactor from "the build is slow". The number you need is this: how many seconds does an incremental build take after a realistic change to a specific file. Measure it separately for a leaf feature file and for a core module file, and the gap between the two is what your graph costs you every day.
Running touch on the file is not enough, because as we saw, when the content does not change the compiled module does not change and the wave does not travel upwards. So the script adds a real declaration:
#!/bin/bash
# usage: ./probe.sh Modules/Core/Sources/Core/Money.swift
set -euo pipefail
FILE="$1"
BUILD=(xcodebuild build -workspace Travel.xcworkspace -scheme Travel
-destination 'generic/platform=iOS Simulator' -quiet)
"${BUILD[@]}" # warm-up: bring everything up to date
echo "public func _probe_$(date +%s)() {}" >> "$FILE"
START=$(date +%s)
"${BUILD[@]}" # the incremental build being measured
END=$(date +%s)
git checkout -- "$FILE" # revert the change
echo "$FILE: $((END - START)) s"
A few rules keep the measurement repeatable: same machine, Xcode closed (so indexing stays out of it), at least three runs per file, and take the median. Make sure the file is clean in git, since the script ends with git checkout. If you also want to see which tasks ran, pass -showBuildTimingSummary instead of -quiet.
Run it for one file from each of the top five modules in the fan-in list and for two or three leaf feature files, and write the results in a table. In most projects the table tells the same story: leaf files finish in seconds, and one or two core modules take close to a clean build. You start the refactor with those one or two modules, and leave the ugliest-looking module for later.
Repeating the measurement with a second kind of change is instructive: replace the probe line with a line added to the body of an existing function. The difference between the two shows how far the rule "body changes are cheap, declaration changes are expensive" holds in your toolchain.
Where third-party dependencies belong in the graph
A third-party library should be imported by exactly one module in the graph. Networking wraps Alamofire, DesignSystem or a small ImageLoading wraps Kingfisher, Analytics wraps the analytics SDK; features see only your module. With InternalImportsByDefault on, the compiler checks that the wrapper has no holes:
// Analytics/Analytics.swift
internal import FirebaseAnalytics
public struct AnalyticsEvent: Sendable {
public let name: String
public let parameters: [String: String]
public init(name: String, parameters: [String: String] = [:]) {
self.name = name
self.parameters = parameters
}
}
public struct AnalyticsTracker: Sendable {
public init() {}
public func track(_ event: AnalyticsEvent) {
// The only place that touches FirebaseAnalytics
FirebaseAnalytics.Analytics.logEvent(event.name, parameters: event.parameters)
}
}
Note that the event definitions (searchStarted, bookingCompleted) do not live in this module. They belong to the features; Analytics is only the transport. Put the event list here and you are changing a high fan-in module every sprint, which is exactly what we set out to avoid.
The build-side gain from wrapping shows up when you bump the library version: one module absorbs the changed API. Wrapping also shrinks the variant problem, because the number of targets compiling against the library with different settings drops to one.
Macros and swift-syntax
When you depend on a package that defines or uses macros, swift-syntax enters the graph, and it is a large package that adds a noticeable amount to a clean build when compiled from source. The current situation: according to the Xcode 26 release notes, for macro targets a prebuilt swift-syntax library is downloaded from swift.org and integrated into the build, and the feature is enabled automatically. According to the announcement on the Swift forums, SwiftPM uses a prebuilt binary when one exists for the resolved swift-syntax version, the host platform and the compiler version, and otherwise builds from source as before.
The practical rules that follow: check in the build log whether the prebuilt is actually being used for the swift-syntax version in your Package.resolved (if you see swift-syntax modules being compiled, it is not). Make sure your CI machines can download that binary. And before adding a macro package, look at how many modules will import it: a macro dependency added to a bottom module like Models puts a step in front of the entire graph. Whether that is worth it for a single @Something is a question the probe script answers.
A worked refactor: out of a bad graph in three steps
The starting state of our travel app, which is the state we often find in the field:
App
├─ Booking ──► Search ──► Payments ──► Core
├─ Search ──► Payments
├─ Payments ──► Core
├─ Profile ──► Booking ──► ...
└─ (36 more features) ──► Core
Core: Money, FlightOffer, HTTPClient, AnalyticsEvents, FeatureFlags,
Colors, Date+Format, MockHTTPClient (#if DEBUG)
Core ──► Alamofire, Kingfisher, FirebaseAnalytics (types in the public API)
In this graph the features are chained to each other (Profile → Booking → Search → Payments), Core is both the most imported and the most frequently changed module, third-party types have leaked everywhere, and the mocks live inside the production module. The probe script reports close to a clean build for Core/AnalyticsEvents.swift.
Three changes fix most of it.
First, split Core by rate of change. Rarely changing value types like Money and FlightOffer go to Models and stay at the bottom. HTTPClient moves to Networking, colours to DesignSystem, the analytics mechanism to Analytics. The frequently changing things, such as the event list and feature flag keys, are distributed into the features that own them. No module called Core remains. Features import only what they use; if Profile does not need Networking, it is not recompiled when Networking changes.
Second, replace feature-to-feature edges with interface modules. The Booking → Search edge becomes Booking → SearchInterface, and Search → Payments becomes Search → PaymentsInterface. The chain breaks, the graph drops to four layers, and features build independently and in parallel.
Third, close off third parties and test-support code. Turn on InternalImportsByDefault in every target, and wherever the compiler complains either write public import deliberately or wrap the leaking type. MockHTTPClient moves to a NetworkingTestSupport target.
App (composition root)
├─ Booking ──► SearchInterface, PaymentsInterface, Models, DesignSystem
├─ Search ──► SearchInterface, PaymentsInterface, Models, Networking, DesignSystem
├─ Payments ──► PaymentsInterface, Models, Networking, Analytics
└─ Profile ──► BookingInterface, Models, DesignSystem
Networking ──► Models, (internal) Alamofire
Analytics ──► (internal) FirebaseAnalytics
DesignSystem ──► (internal) Kingfisher
NetworkingTestSupport ──► Networking (imported by test targets only)
The order matters: step one first, because the largest gain is there and it makes the other two easier. Each step should be its own series of PRs; try to do all three on one branch and that branch will never be merged.
To confirm the gain, save the output of the probe script before you start and rerun it after each step for the same files (or their counterparts in the new locations if they moved). Three things to look at: the incremental time for the former core files, Booking not appearing at all in the timing summary after a declaration change inside Search, and the top of the fan-in list now holding only modules that rarely change. If the third does not hold, meaning the git history of the module at the top of the list is still busy, you have renamed Core without splitting it.
There is also a point where this stops being worth it. In a project with fifteen modules and a clean build of a few minutes, interface modules and a split Core may not buy you anything measurable; if the gap between leaf and core in your probe table is small, your graph is already in good shape and your time is better spent elsewhere.
Checklist
- The target-level graph has been extracted; the top five modules by fan-in are known.
- For each of those five, the commit count over the last three months was checked; modules that are both widely imported and frequently changed are flagged.
- Incremental build time for leaf and core files was measured with the probe script and recorded.
- No feature imports another feature's implementation, and CI checks this.
- The longest path from the app to the lowest module does not exceed four layers.
Core/Common/Sharedstyle modules are split by rate of change; new module names say what the module does.InternalImportsByDefaultis on in every target;public importlines are deliberate and few.- Each third-party library is imported by exactly one module and its types do not appear in public API.
packageis used instead ofpublicfor sharing inside the package.- Mocks and fixtures live in separate test-support targets; no
#if DEBUGmocks in production modules. - Compiler settings for all targets come from a single source; the timing summary shows no unnecessary module variants.
- For macro dependencies, the build log confirms swift-syntax arrives prebuilt.
- After the refactor the same measurements were repeated and compared with the baseline.
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.
Sources
- Apple · Building your project with explicit module dependencies
- Apple · Improving the speed of incremental builds
- Apple · Xcode 26 Release Notes
- Apple · Build settings reference
- Swift Evolution · SE-0409 Access-level modifiers on import declarations
- Swift Evolution · SE-0386 New access modifier: package
- Swift · Dependency Analysis
- Swift Package Manager · swift package show-dependencies
- Swift Forums · Swift-Syntax Prebuilts for Macros
- Apple · WWDC24, Demystify explicitly built modules
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.