Home

Splitting an app into local Swift packages

I split a 60,000-line app into eleven local Swift packages over about three weeks. It was worth doing, it was not the free win the articles suggest, and I got the dependency structure wrong on the first attempt in a way that took a week to unwind.

What it actually bought

Faster incremental builds. Editing a view rebuilds its feature module and the app shell, not the world. 90 seconds to about 25.

Enforced boundaries. This is the real prize, and it is the one that outlasts the build times. A module cannot use something it has not declared a dependency on, so the “just import it and move on” shortcut becomes a compile error. Architecture rules that used to live in a wiki are now enforced by the compiler.

Previews that work. A feature module with a small dependency graph builds fast enough that SwiftUI previews are usable. In the monolith they timed out constantly, which meant nobody used them, which meant the design feedback loop was “run the whole app”.

Tests that run in seconds. Testing one module compiles one module.

The structure that worked

Four tiers, with dependencies only ever pointing downward:

App                       — the shell, wiring, app delegate
  ↓
Features/                 — Search, Library, Settings, Reader
  ↓
Core/                     — Networking, Persistence, Analytics
  ↓
Foundation/               — DesignSystem, Extensions, Models

The rules:

  • Features never import other features. Anything shared moves down a tier.
  • Core never imports Features. If Core needs something from a feature, the design is wrong.
  • Foundation imports nothing of ours.

The mistake

My first attempt had a Shared module that everything depended on. It started with three types and grew to about eighty, because “shared” has no definition and everything is shareable if you squint.

Within two months, Shared imported networking (for an API model), the design system (for a colour enum), and persistence (for a Codable helper). Every module depended on Shared, so every module transitively depended on everything. Build times went back to where they started and the boundaries stopped meaning anything.

Warning

A module named Shared, Common, Core, or Utils with no stated rule about what belongs in it will become a dependency magnet. The fix is a name that excludes things: DesignSystem tells you what does not belong; Shared does not.

Unwinding it took a week: splitting Shared into Models, DesignSystem and Extensions, then fixing every import. I would rather have spent an hour on the naming.

The costs nobody mentions

Resources need declaring. Assets, localised strings and any bundled file need a resources: entry in Package.swift, and Bundle.module instead of Bundle.main. Missing this fails at runtime, not at build time — a missing image is a blank space in a release build.

.target(
    name: "DesignSystem",
    resources: [.process("Resources")]
)

Access control becomes real work. Everything defaults to internal, which is now module-scoped. Every type another module uses needs public, and every initialiser too — a public struct with a synthesised init is not constructible from outside, and the error message does not explain why.

Circular dependencies are a refactor. Two features that want to reference each other cannot, and SPM’s error tells you there is a cycle but not what to do about it. The answer is always to extract the shared part downward or to invert the dependency with a protocol, and both take real time.

Xcode gets slower at some things. Indexing across many packages, jumping to definition, and resolving packages after a Package.swift change are all noticeably slower than in a monolith.

Static or dynamic

Default to static. Dynamic frameworks are loaded with dlopen at launch, and each one costs real milliseconds — this was the single biggest contributor to a slow cold launch in the same app.

.library(name: "DesignSystem", type: .static, targets: ["DesignSystem"])

Leave the type: off entirely and SPM decides, which is fine for most modules. Specify .dynamic only when a module is genuinely shared with an app extension — otherwise the code is linked into both binaries.

Whether to do it

Worth it if the app is over roughly 30,000 lines, more than two or three people work on it, build times are hurting, or the architecture keeps being violated because nothing enforces it.

Not worth it for a solo app under about 20,000 lines. The boundary maintenance is a real ongoing cost and the build-time win is small when there is not much to rebuild.

Do it incrementally. Extract one leaf module — a design system or a models package, something with no dependencies — and live with it for a week before extracting the next. The temptation to plan all eleven modules up front is strong, and every plan I have made that way has been wrong about where the boundaries actually fall.