8 min read
Your Public Protocol Is Why the Whole App Recompiles
Your Public Protocol Is Why the Whole App Recompiles

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 changedWhat rebuilds
Internal implementationThat module only
Public signatureThat module + everything downstream
Added a file, nothing publicThat module only
Promoted internal to publicThat module + everything downstream, from now on
Whole Module set on DebugEffectively everything, every time

Three habits:

  1. Treat public as a cost, not a convenience. It’s the keyword that decides your rebuild scope.
  2. Group the shared layer by how often things change, not by what kind of thing they are.
  3. 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