I split an app into twelve modules and the build got slower.
Not dramatically. Just enough that the thing I did to speed up iteration had quietly done the opposite, and it took me an embarrassingly long time to notice — because every piece of advice I’d read said modularizing was the fix.
The advice isn’t wrong. It’s just incomplete in a way that matters. Modules only isolate rebuilds if they isolate interfaces, and the module I’d put all my shared protocols in was doing the exact opposite.
What the Compiler Actually Tracks
When you build a Swift module, the compiler emits a .swiftmodule file. That file is a binary representation of the module’s public interface — the declarations other modules are allowed to see.
The build system hashes it. Downstream modules rebuild when that hash changes, and skip rebuilding when it doesn’t.
That’s the whole mechanism. Rebuild scope follows public surface area, not file count and not module count.
Which means you can add ten files to a module and trigger nothing downstream, or change one line and rebuild everything.
Proving It on Your Own Machine
This takes about two minutes and it’s worth doing, because the behavior is more specific than most descriptions of it.
Make a two-target package:
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "ModDemo",
products: [.library(name: "FeatureAuth", targets: ["FeatureAuth"])],
targets: [
.target(name: "CoreModels"),
.target(name: "FeatureAuth", dependencies: ["CoreModels"]),
]
)
Give CoreModels one public protocol and one internal type:
// Sources/CoreModels/Auth.swift
public protocol AuthServiceProtocol {
func signIn(email: String) async throws -> String
}
internal struct InternalHelper {
var tag = "a" // Nothing outside this module can see this
}
And have FeatureAuth depend on the protocol:
// Sources/FeatureAuth/Login.swift
import CoreModels
public struct LoginFlow {
let service: any AuthServiceProtocol
public init(service: any AuthServiceProtocol) { self.service = service }
public func run(email: String) async throws -> String {
try await service.signIn(email: email)
}
}
Run swift build once to get a clean baseline. Now change only the internal detail — tag = "a" becomes tag = "b" — and build again:
[3/4] Compiling CoreModels Auth.swift
[4/4] Emitting module CoreModels
Build complete!
CoreModels rebuilt. FeatureAuth didn’t. The public interface didn’t move, so nothing downstream had to care.
Now add a parameter to the public protocol:
public protocol AuthServiceProtocol {
// ⚠️ One new parameter — every downstream module rebuilds
func signIn(email: String, otp: String?) async throws -> String
}
Build again:
[4/5] Compiling CoreModels Auth.swift
[5/5] Emitting module CoreModels
[6/7] Compiling FeatureAuth Login.swift
[7/7] Emitting module FeatureAuth
Build complete!
Both modules. Same file, same number of lines changed, completely different blast radius.
The step ordering shifts between runs since the build system interleaves compiling and module emission — what matters is which module names appear at all.
Key insight: the boundary that matters is public, not the module folder. A module whose public interface changes constantly is a module that isolates nothing.
Why This Bites Modular Apps Specifically
Here’s the shape almost everyone converges on. A shared module at the bottom — call it CoreModels — holding domain models and the service protocols every feature depends on. Feature modules above it. An app target on top wiring everything together.
It’s a good structure. I’ve recommended it. It also puts your most-referenced declarations in one place and marks them all public, because they have to be public for the feature modules to use them.
So the module at the bottom of the graph, which everything depends on, is the one whose public surface you touch every time you add a field to a model or a method to a service protocol. Every one of those edits rebuilds the world.
The failure isn’t the layering. It’s that the shared layer accumulated everything volatile.
What Actually Helps
Keep Volatile Things Internal
The default access level in Swift is internal, and that default is doing you a favor. Every declaration you promote to public is a declaration whose changes now propagate.
Before marking something public, check whether it needs to be:
// ✅ Public — other modules genuinely call this
public protocol AuthServiceProtocol {
func signIn(email: String) async throws -> String
}
// ✅ Internal — an implementation detail that used to be public
// for no reason, and was rebuilding four modules on every tweak
internal struct TokenCache {
var entries: [String: String] = [:]
}
The tradeoff is real: sometimes you’ll want a type in two modules and have to either promote it or duplicate it. Duplicating a small value type across two modules is often the cheaper choice, and it feels wrong until you compare it against the rebuild cost.
Split the Shared Module by Volatility
If your shared module holds both a User model that hasn’t changed in a year and a service protocol you edit weekly, those don’t belong together. The stable half is doing no harm. The volatile half is invalidating everything.
Splitting them means the weekly churn only rebuilds what depends on the churning half:
CoreModels — domain types, changes rarely
ServiceContracts — service protocols, changes weekly
Feature modules that only need models stop rebuilding when a protocol moves.
The tradeoff? More modules, more Package.swift to maintain, and one more decision every time you add a type. Worth it when the churn is real, pure overhead when it isn’t.
Stop Guessing and Measure
Two flags tell you where type-checking time actually goes. Add them to Other Swift Flags, or run them directly:
swiftc -typecheck -Xfrontend -warn-long-function-bodies=100 file.swift
swiftc -typecheck -Xfrontend -warn-long-expression-type-checking=100 file.swift
They warn on anything over the threshold in milliseconds:
warning: global function 'slowExpression()' took 60ms to type-check (limit: 1ms)
warning: expression took 25ms to type-check (limit: 1ms)
Start at 100ms and lower it until you get a manageable list. What surfaces is usually a handful of expressions where type inference is doing far more work than you’d guess — often arithmetic mixing numeric types, or a long chain of collection operations. An explicit type annotation frequently cuts them to nothing.
For per-file numbers, -stats-output-dir writes JSON stats per frontend invocation:
swiftc -typecheck -stats-output-dir stats/ file.swift
One warning about older advice: you’ll find -driver-time-compilation recommended in a lot of build-time posts. On Swift 6.3.3 it produced no output for me — it depends on the legacy driver. Use -stats-output-dir instead.
Check Your Debug Configuration
Debug builds should use Incremental compilation with No Optimization. Release builds should use Whole Module. This has been the recommended pairing since Xcode 10, and projects that have been around a while sometimes still carry a Whole Module debug setting from an older era — which defeats incremental builds entirely.
It costs nothing to check, and it’s the biggest single win in this article if it happens to be wrong.
The Honest Limits
Swift’s incremental compilation isn’t precise. Even with clean module boundaries, you will hit rebuilds that make no sense — the community’s standing complaint is that changing one string somewhere causes most of a project to recompile, and that’s a real thing, not user error.
So calibrate expectations. Narrowing public surface makes rebuild scope more predictable and usually smaller. It doesn’t give you fine-grained control, and no amount of module hygiene will.
Modules also aren’t free. Each one costs a Package.swift entry, a module boundary to import across, and a small fixed build cost of its own. Twelve modules with sloppy public surfaces are strictly worse than four with tight ones.
The Mental Model
| What you changed | What rebuilds |
|---|---|
| Internal implementation | That module only |
| Public signature | That module + everything downstream |
| Added a file, nothing public | That module only |
| Promoted internal to public | That module + everything downstream, from now on |
| Whole Module set on Debug | Effectively everything, every time |
Three habits:
- Treat
publicas a cost, not a convenience. It’s the keyword that decides your rebuild scope. - Group the shared layer by how often things change, not by what kind of thing they are.
- Measure before restructuring. The flags above take five minutes and will sometimes tell you the problem is one pathological expression, not your architecture.
What to Do Monday
Open your shared module and read the public declarations. Ask which ones are public because another module calls them, and which are public because it was easier than thinking about it.
The second group is your build time.
Further Reading
- Optimizing Swift Build Times — the long-running community collection of measurement flags and mitigations.
- Swift Package Manager documentation — target and product semantics, and how dependencies between them are declared.
- Access Control — The Swift Programming Language — what each access level means at a module boundary.