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.