Every large iOS codebase reaches the same inflection point. Incremental builds slow from seconds to minutes. Merges collide because every pull request touches the same model files. Teams step on each other because the only architecture boundary is a folder name, and folders do not enforce anything.
Swift Package Manager turned from a dependency manager for third-party libraries into the standard way iOS teams build modular architecture. This guide covers what I have learned watching teams modularize — and not modularize — real apps: the module topology that works, the dependency rules that keep it acyclic, the access-control discipline that prevents leakage, and the honest truth about build times. Plus an incremental migration path that keeps the app green the whole way.
When upgrading those packages, the Swift 6.4 migration guide explains how to validate the new default build engine, plugins, generated sources, and CI toolchains from a clean checkout.
1. Why Modularize — and When Not To
The arguments for modularization are real, but let me be precise about what each one actually buys you.
Faster incremental builds. When you change a file, only the module that contains it — and the modules that depend on it — recompile. On a ten-module app, a change inside CartModule does not touch ProfileModule at all. That is the core win, and it compounds as the app grows. (Section 6 covers what does not get faster.)
Enforced boundaries. A module boundary is the only boundary the compiler enforces. A folder says “these files are the domain layer.” A module says nothing outside this target can see these files — unless you explicitly mark them public. That enforcement is worth more than any amount of naming convention, because it turns architecture violations from a review comment into a compile error.
Testability. Business rules that live in a module with zero UI dependencies can be tested without a host app, without @testable import gymnastics into the app target, and — with Swift Testing — in parallel, on any machine with the Swift toolchain, no simulator required.
When that pure domain package has real cross-platform value, sharing iOS business logic with Swift on Android shows how to preserve the boundary while adding Android compilation and Kotlin interop.
Parallel team workflows. If CatalogModule and CartModule do not import each other, two teams can own them outright. Their PRs stop colliding in the same files, and code review stays within the owning team.
Now the part most blog posts skip. Do not modularize a small app. If your target builds in under a minute, if you are a solo developer, if this is a prototype you might delete — modularization is a cost with no payoff. Every module is a Package.swift to maintain, a visibility decision to make, a resource and localization pipeline to manage. I have seen a three-screen app with five packages. It was slower to build, harder to navigate, and the added ceremony produced zero architectural benefit, because the whole app was one team and one mental model anyway. Start modularizing when the pain is real: sustained multi-minute builds, frequent merge conflicts at the app layer, or multiple teams shipping to one target.
2. Target Topology: Features, Domain, and a Thin App Shell
A topology that has worked well across many production apps looks like this:
App (thin shell — composition only)
├── CatalogModule ──▶ Domain, DesignSystem
├── CartModule ──▶ Domain, DesignSystem
├── ProfileModule ──▶ Domain, DesignSystem
├── Domain ──▶ (nothing — no UIKit, no SwiftUI)
└── DesignSystem ──▶ (nothing)
Feature modules (CatalogModule, CartModule, ProfileModule) own one vertical slice: the screens, the view models, and the feature’s local networking or persistence adapters. Each one is a Swift package exposing a plain .library product.
Domain is the keystone: pure models and business rules with no UI frameworks and no dependencies on other modules. It is the only module that everyone may import, which is exactly why it must stay tiny and stable. Moving a file into Domain is a serious decision — it becomes a dependency of everything.
DesignSystem holds reusable UI: tokens, typography, components, and the app’s asset catalog. Feature modules depend on it so screens look consistent without importing each other.
The App target becomes a thin shell. It has no business logic and no feature screens of its own. It only composes: it instantiates concrete implementations, wires them to the protocols features declare, and hands the whole graph to the root NavigationStack. This shell is exactly the composition root from MVVM with Clean Architecture in iOS: A Practical Guide — the module boundaries map one-to-one onto the architectural layers, with Domain staying pure and presentation code confined to features.
System-facing targets need the same discipline: sharing App Intents across an app and its extensions requires explicit target membership, resources, entitlements, and process-safe dependencies.
3. Package Setup Mechanics
Each module is a small Swift package on disk. A realistic feature manifest:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "CatalogModule",
defaultLocalization: "en",
platforms: [.iOS(.v17)],
products: [
.library(name: "CatalogModule", targets: ["CatalogModule"])
],
dependencies: [
.package(path: "../Domain"),
.package(path: "../DesignSystem")
],
targets: [
.target(
name: "CatalogModule",
dependencies: ["Domain", "DesignSystem"],
resources: [.process("Resources")]
),
.testTarget(
name: "CatalogModuleTests",
dependencies: ["CatalogModule"]
)
]
)
In Xcode, the integration path is File > Add Package Dependencies… > Add Local…, then select the package folder. The package appears under “Local Packages” in the project navigator. Xcode resolves the cross-package path: dependencies at the workspace level, so you do not maintain a giant root Package.swift — each module declares its own edges, and the app project ties them together.
A practical decision: local packages in the same repo vs. remote packages. If you are one team, keep the app and its packages in one monorepo and use .package(path:). If a package genuinely serves multiple apps, give it its own repo and a semantic version. Do not remote-host packages only one app uses — you gain nothing but version-tag churn.
Resources work differently inside packages. SPM generates a Bundle.module for every target that declares resources, and anything you list in the manifest lands there:
// DesignSystem
import SwiftUI
public struct BrandMark: View {
public init() {}
public var body: some View {
Image("brand-mark", bundle: .module)
}
}
You declare resources with .process("Resources") (applies platform-specific processing, like asset catalog compilation and string catalog extraction) or .copy(...) (verbatim). Set defaultLocalization in the manifest or your Localizable.xcstrings will not resolve correctly.
Privacy manifests (Xcode 15+) ship per module. Each package that collects data — including indirectly, through analytics SDKs it wraps — should include a PrivacyInfo.xcprivacy file:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
</dict>
</plist>
Declare it in the target’s resources (.process("PrivacyInfo.xcprivacy")), and it gets merged into the app’s privacy report at archive time. Do this when you create the package, not when App Review asks.
4. Dependency Rules: Acyclic, Inverted, and Enforced
The single most important rule: the module graph must be acyclic. The compiler enforces this at the package level — if CatalogModule and CartModule both try to import each other, the build fails with a cycle error. Good. That enforcement is exactly why modules work where folders fail.
But acyclic alone is not enough. Three rules keep the graph sane:
Rule 1 — Domain imports nothing. If Domain imports SwiftUI — or a networking library like Alamofire — every feature that imports Domain inherits that dependency edge, and the “pure logic” module stops being pure: its API surface now implies a UI framework or a transport stack to anyone downstream. Keep it to Foundation and the standard library.
Rule 2 — Depend on the module that owns the data. If two features need the same model, the model belongs in Domain, not in a shared “Models” grab-bag module that everyone imports. If two features need the same network client, that client is a foundation concern, not a feature concern.
Rule 3 — Invert dependencies at the seams. When CatalogModule needs to add items to the cart, the naive move is to import CartModule. That creates the cycle — CartModule surely wants to show catalog items too. The fix is dependency inversion: define the protocol in the module that needs it, and let the app target provide the concrete implementation:
// CatalogModule — the seam lives where the consumer lives
import Domain
public protocol CartAdding {
func add(_ item: Item, quantity: Int) async throws
}
public struct CatalogViewModel {
private let cart: any CartAdding
public init(cart: any CartAdding) {
self.cart = cart
}
public func add(_ item: Item, quantity: Int) async throws {
try await cart.add(item, quantity: quantity)
}
}
Now CatalogModule has zero knowledge of CartModule. The app shell knows both, so it can wire them:
// App target — the only place that knows every concrete type
let cart: any CartAdding = CartService() // lives in CartModule
let catalog = CatalogViewModel(cart: cart)
This is dependency inversion in its purest form, and it is the same principle behind why Swift is protocol-oriented and why that matters: you code against the capability you need, defined where you need it, not against a concrete type from another module.
What the compiler does not enforce is indirect coupling. Two modules can avoid an import cycle while still depending on the same bloated shared module, or exchanging strings through UserDefaults and NotificationCenter. That discipline — noticing that Notification.Name("cart.didUpdate") in ProfileModule is really a hidden edge back to CartModule — is on you.
5. Visibility Discipline: The Public Surface Is a Contract
internal is the default in Swift, and inside a module that is exactly right: most types and functions should be internal, invisible to every other module. The discipline is to decide, for each declaration, who is allowed to see this — because in a modularized app, that decision is now compile-time enforced.
Three access levels matter:
internal— the module’s private implementation. Default, and correct for ~90% of declarations.package(Swift 5.9+, SE-0386) — visible to other targets within the same package, invisible to consumers. Genuinely useful when one package contains several targets that share helpers you do not want public. The honest caveat: in the common monorepo layout where each module is its own package,packageis a no-op — your cross-module sharing needspublicanyway.public— the module’s API. This is a contract. Changing apublicsignature is a breaking change for every downstream module, whether it is your own app code or a remote consumer.
The “everything public” trap is the most common modularization failure I see. Teams mark every type public to “save time,” and the module boundary evaporates: anything can reach into anything, which recreates the monolith with extra ceremony. Worse, the public surface becomes a maintenance liability — every public initializer must be kept compiling, every public type constrains refactoring, and the module’s real API is buried under noise. A rule of thumb: if a declaration has no consumer outside the module today, it is internal today. Make it public when a real consumer needs it, not preemptively.
6. Build-Time Reality: What Actually Speeds Up
Let me separate fact from folklore, because modularization is routinely sold as a build-time miracle and it is not.
What actually gets faster:
- Change-scoped recompilation. Edit one file in
ProfileModuleand onlyProfileModulerebuilds — plus its one dependent, the thin app shell, which has almost nothing of its own to recompile.CatalogModule,Domain,DesignSystemare untouched. This is the real, compounding win: build time stops scaling with the whole codebase and starts scaling with the change. - Parallel module builds. Modules with no dependency between them build concurrently, so Xcode can keep all cores busy. On a modern Mac this meaningfully cuts wall-clock time for changes that touch several modules.
- Faster test feedback. A module’s test target rebuilds quickly because only that module — not the app — is in the loop.
What does not get faster:
- Clean builds. Building everything from scratch still compiles every module. Modularization barely helps cold CI builds.
- Changes to low-level modules. Touch one file in
Domainand every module that imports it rebuilds. This is the hidden tax on overpopulating Domain — keep it small precisely because it is expensive to change. - App-target code. The app shell itself still contains SwiftUI composition, the root view, and app lifecycle. A change there still triggers a full app build. If your app target is still 50,000 lines, you will not see the headline build-time wins until the bulk of code lives in modules.
And one more honest note: for a small app, a single target with good incremental builds may already be fast enough, and the modular overhead can make builds slower in wall-clock terms. Measure first. If your warm build is twenty seconds, modularize for architecture and team structure — not for build time.
7. Testing Across Modules
When code moves into a module, its tests move with it. Each feature package carries its own CatalogModuleTests target (see the manifest in Section 3), and tests run against the module alone — no app launch, no host, no simulator needed.
Two strategies for reaching into the module:
@testable import — tests internal declarations directly. This is the default choice for feature logic and view models, and it keeps the public surface honest: you do not need to mark things public just to test them.
Public-API-only testing — a testTarget that imports the library without @testable, which compiles as a consumer would see it. This is a powerful discipline: if a test can only exercise public API, the module’s internals are free to change, and the public surface is proven sufficient. Use it for Domain and DesignSystem.
The testTarget manifest:
.testTarget(
name: "CatalogModuleTests",
dependencies: ["CatalogModule"]
)
And with Swift Testing, a module test is just a function. The CartSpy conformer and the fake live in the test target, which is exactly the point — the module’s tests bring their own doubles and never leak them into production:
// CatalogModuleTests
import Testing
@testable import CatalogModule
import Domain
struct CatalogViewModelTests {
@Test("Adding an item forwards to the cart")
func addItemForwardsToCart() async throws {
let cart = CartSpy()
let viewModel = CatalogViewModel(cart: cart)
try await viewModel.add(Item(id: UUID(), name: "Mug", unitPrice: 12.5), quantity: 2)
#expect(cart.addedItems.count == 1)
}
}
I covered the migration from XCTest, parallel execution, and the trait system in Mastering Swift Testing — inside packages, the story is identical, with the bonus that module tests run in parallel by default on any machine with a Swift toolchain.
8. Incremental Migration: Module by Module, App Always Green
Do not do a big-bang rewrite. The pragmatic path moves code module by module while the app target keeps building and shipping. Start with the leaves of the dependency graph — Domain, then DesignSystem — because everything else depends on them. Extract pure models and business rules first: they are the easiest to move (no UI), and they pay off immediately because they unblock everything downstream.
Along the way you will hit the same four failure modes every team hits. Know them in advance:
Global singletons. Session.shared, APIClient.shared, Database.shared. A feature module that touches a singleton must import the singleton’s home module, which drags in the wrong edges — the very coupling you are trying to remove. Options: move the singleton into a foundation module that features may import, or (cleaner) wrap it behind a protocol with an injected instance — the facade and dependency injection patterns from Design Patterns in Swift: A Practical Guide are exactly the toolkit for this step.
God objects. The 5,000-line UserStore that mixes networking, persistence, formatting, and analytics. It cannot be assigned to any one module, so it blocks whatever depends on it. Slice it along its seams — persistence goes to a Data layer, formatting goes to the feature that displays it, analytics goes behind a protocol — then delete it.
Cross-module Entity/model sharing. A single Models.swift that every file in the app imports. The moment CatalogModule and ProfileModule both depend on it, you have recreated the monolith as a dependency. Fix: move the shared core into Domain, and give each feature its own DTOs with mappers at the boundary. Yes, that is real work — it is also the work that actually decouples the app.
Notification-name coupling. Notification.Name("cart.didUpdate") broadcast in one module and observed in another is a hidden edge that the compiler cannot see. When you extract a module, the string still compiles — and the coupling silently survives. Move the name constant into the module that owns the event (CartModule), or replace the broadcast with an explicit protocol callback or an AsyncSequence. Delete the stringly-typed edges while you still control the diff.
None of these fail loudly. The app keeps compiling, the singletons keep working, and the graph slowly gets worse. That is why migration needs a reviewing eye: every merge should make the module graph better, not just keep it green.
Key Takeaways
- Modularize for enforced boundaries, team parallelism, and incremental build times — but only once the pain is real. For small apps and prototypes, a single target is the right architecture.
- The topology that scales: thin app shell, feature modules, a tiny pure
Domain, and aDesignSystem. The app target composes; everything else is a package. - Acyclic by construction, inverted at the seams: define protocols where the consumer lives, and let the composition root wire concretes.
- The public surface is a contract. Default to
internal, promote topubliconly when a real consumer exists. - Build times improve for incremental and parallel builds — not clean builds, not Domain changes, not app-target code.
- Tests travel with their modules, via
@testableor public-API-only suites, using Swift Testing. - Migrate incrementally, and watch for the four silent killers: singletons, god objects, shared model files, and stringly-typed notifications.
Modularization is not an architecture you install; it is a boundary discipline you maintain. The compiler holds the lines you declare — but you decide where the lines are.