Modular App Architecture: Splitting Your App into Swift Packages
Your app’s build time hit four minutes, and clean builds take eight. Three developers are constantly in merge conflicts on the same files. Features that were supposed to be reusable are buried in the main target with no way to share them across a widget extension or a new app target. Modular architecture isn’t optional at scale — it’s survival.
This post covers converting a monolithic iOS app into a graph of local Swift Package Manager modules. You’ll see how to
define the module hierarchy, create Package.swift manifests, enforce dependency direction with access control, and
measure the build time improvements. We won’t cover remote packages for third-party dependencies or binary targets —
those get their own treatment in a follow-up post.
This guide assumes you understand Swift packages basics and access control. If either is unfamiliar, read those posts first.
Contents
- The Problem
- Designing the Module Hierarchy
- Creating Local Packages
- A Complete Package.swift: NetworkingKit
- Access Control Discipline
- Wiring Modules into the App Target
- Testing in Isolation
- SwiftUI Previews in Feature Packages
- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem
A monolithic Xcode project organizes code by layer — a flat folder structure that feels logical at first:
PixarApp/
├── Models/
│ ├── PixarFilm.swift
│ ├── Character.swift
│ └── Studio.swift
├── Views/
│ ├── FilmsListView.swift
│ ├── FilmDetailView.swift
│ ├── CharactersView.swift
│ └── StudioView.swift
├── ViewModels/
│ ├── FilmsViewModel.swift
│ └── CharactersViewModel.swift
├── Services/
│ ├── FilmsService.swift
│ ├── CharactersService.swift
│ └── NetworkClient.swift
└── Utilities/
├── ImageCache.swift
└── DateFormatter+Extensions.swift
The problem with this structure isn’t obvious until the codebase grows. Every file lives in the same PixarApp module,
so every type is visible to every other type. You can call FilmsViewModel from inside NetworkClient.swift — the
compiler won’t stop you. Circular dependencies emerge organically as the team grows and shortcuts accumulate.
More critically: the Swift compiler treats your main app target as a single compilation unit. When you change
DateFormatter+Extensions.swift, it may trigger a recompile of files that have no logical connection to date
formatting. At 150,000 lines of code, you’ll feel this as multi-minute build times.
Here’s what that looks like in practice. A feature that should be encapsulated leaks its dependencies:
// ❌ In a monolithic target, this compiles — and nobody catches it until production
final class CharactersViewModel: ObservableObject {
// Direct dependency on a Films-specific type: no architectural boundary stops it
private let filmsCache: FilmsCacheManager
// Direct URLSession usage — can't be tested without network
func loadCharacters(for filmID: String) async throws {
let url = URL(string: "https://api.pixar.com/v1/films/\(filmID)/characters")!
var request = URLRequest(url: url)
request.setValue("Bearer \(AuthManager.shared.token)", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
// ...
}
}
The solution isn’t discipline — discipline doesn’t scale across teams or time. The solution is making illegal dependencies unrepresentable by putting modules in separate Swift packages that can only see what you explicitly expose.
Designing the Module Hierarchy
Before writing a single Package.swift, design the dependency graph on paper. The rule is simple: feature modules
depend on shared libraries; shared libraries never depend on feature modules; feature modules never depend on each other
directly.
PixarApp (main Xcode target)
├── FilmsFeature ──┐
├── CharactersFeature ──┤──► NetworkingKit
├── StudioFeature ──┘──► SharedModels
│ ──► DesignSystem
├── NetworkingKit (no app-level dependencies)
├── SharedModels (no app-level dependencies)
└── DesignSystem (no app-level dependencies)
This graph has three layers:
- Shared libraries (
NetworkingKit,SharedModels,DesignSystem) — zero app dependencies. They contain infrastructure, domain types, and UI primitives. Any target in the universe can import them. - Feature modules (
FilmsFeature,CharactersFeature,StudioFeature) — depend on shared libraries, never on other feature modules. Each one is a vertical slice of UI + business logic for a single feature area. - App target (
PixarApp) — the thin integration layer. It imports feature modules, configures the dependency graph at launch, and handles app-level concerns: push notifications, deep links, app lifecycle.
The key invariant: if FilmsFeature needs data that lives in CharactersFeature, that data belongs in SharedModels,
not in CharactersFeature. This prevents the circular dependency problem before it starts.
Tip: Draw this graph before you create a single file. Teams that skip this step end up with modules that depend on each other in a cycle, which SPM will reject at build time — sometimes after days of work.
Creating Local Packages
Local SPM packages live inside your repository as subdirectories. A common convention is a top-level Packages/
directory alongside your .xcodeproj:
PixarAppRoot/
├── PixarApp.xcodeproj
├── PixarApp/ (app target sources)
├── Packages/
│ ├── NetworkingKit/
│ │ ├── Package.swift
│ │ └── Sources/
│ │ └── NetworkingKit/
│ ├── SharedModels/
│ │ ├── Package.swift
│ │ └── Sources/
│ │ └── SharedModels/
│ ├── DesignSystem/
│ │ ├── Package.swift
│ │ └── Sources/
│ │ └── DesignSystem/
│ ├── FilmsFeature/
│ │ ├── Package.swift
│ │ └── Sources/
│ │ └── FilmsFeature/
│ └── CharactersFeature/
│ ├── Package.swift
│ └── Sources/
│ └── CharactersFeature/
Create the directory structure:
mkdir -p Packages/NetworkingKit/Sources/NetworkingKit
mkdir -p Packages/NetworkingKit/Tests/NetworkingKitTests
mkdir -p Packages/SharedModels/Sources/SharedModels
mkdir -p Packages/FilmsFeature/Sources/FilmsFeature
mkdir -p Packages/FilmsFeature/Tests/FilmsFeatureTests
Add each package to your Xcode project by dragging the package’s root directory into the project navigator. Xcode
discovers the Package.swift automatically.
Apple Docs: Swift Package Manager — Xcode documentation
A Complete Package.swift: NetworkingKit
NetworkingKit is the networking layer from our
Building a Clean Networking Layer post, extracted into a standalone
package. Here’s its complete Package.swift:
// swift-tools-version: 6.0
// The swift-tools-version declaration specifies the minimum version of SPM required
import PackageDescription
let package = Package(
name: "NetworkingKit",
// Platforms this package supports — feature packages can require higher minimums
platforms: [
.iOS(.v17),
.macOS(.v14)
],
// Products define what other packages can import from this one
products: [
.library(
name: "NetworkingKit",
// Use .dynamic for pre-compiled binary reuse across extensions;
// use .static (default) for smaller total binary size in most cases
targets: ["NetworkingKit"]
)
],
// This package has no external dependencies — a deliberate constraint
dependencies: [],
targets: [
// The library target
.target(
name: "NetworkingKit",
dependencies: [],
path: "Sources/NetworkingKit",
swiftSettings: [
// Opt into Swift 6 strict concurrency checking
.swiftLanguageMode(.v6)
]
),
// The test target — depends on the library target, never the other way around
.testTarget(
name: "NetworkingKitTests",
dependencies: ["NetworkingKit"],
path: "Tests/NetworkingKitTests"
)
]
)
A few decisions worth explaining:
swift-tools-version: 6.0enables Swift 6 language mode and theswiftLanguageModesetting. This is a package-level flag — it doesn’t force callers into Swift 6.- No external dependencies.
NetworkingKitdepends only onFoundation. Keeping shared infrastructure free of third-party dependencies means you can update dependencies in one package without affecting others. - Explicit
platformslist. Without this, SPM defaults to the lowest supported platform, which might allow API calls that don’t exist on your actual deployment target.
For FilmsFeature, which depends on both NetworkingKit and SharedModels, the Package.swift uses local path
dependencies:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "FilmsFeature",
platforms: [.iOS(.v17)],
products: [
.library(name: "FilmsFeature", targets: ["FilmsFeature"])
],
dependencies: [
// Local path dependencies — these are resolved at build time from disk
.package(path: "../NetworkingKit"),
.package(path: "../SharedModels"),
.package(path: "../DesignSystem")
],
targets: [
.target(
name: "FilmsFeature",
dependencies: [
"NetworkingKit",
"SharedModels",
"DesignSystem"
],
path: "Sources/FilmsFeature",
swiftSettings: [.swiftLanguageMode(.v6)]
),
.testTarget(
name: "FilmsFeatureTests",
dependencies: ["FilmsFeature"],
path: "Tests/FilmsFeatureTests"
)
]
)
Warning: Local path dependencies use relative paths from the
Package.swiftfile’s location. If you restructure your directory layout, these paths break silently at build time. Keep yourPackages/directory flat and the relative paths short.
Access Control Discipline
SPM enforces a hard boundary: only public and open declarations cross module boundaries. Everything else is
internal by default and invisible outside the package.
This is the architectural enforcement mechanism. Instead of relying on code review and discipline, the compiler enforces
your intended API surface. In NetworkingKit, only the protocol and error type need to be public:
// Sources/NetworkingKit/APIClient.swift
// Public — consumers need to reference this protocol
public protocol APIClient: Sendable {
func request<T: Decodable & Sendable>(
_ endpoint: some APIEndpoint,
as type: T.Type
) async throws -> T
}
// Public — consumers need to catch and switch on this type
public enum NetworkError: Error, LocalizedError, Sendable {
case unauthorized
case notFound
case serverError(statusCode: Int)
case decodingFailure(DecodingError)
// ...
}
// Internal — implementation detail, never referenced by callers
final class LiveAPIClient: APIClient {
// ...
}
// Sources/NetworkingKit/RequestInterceptor.swift
// Public — consumers compose their own interceptors
public protocol RequestInterceptor: Sendable {
func intercept(_ request: URLRequest) async throws -> URLRequest
}
// Internal — callers don't need to know these implementations exist
struct AuthInterceptor: RequestInterceptor { /* ... */ }
struct LoggingInterceptor: RequestInterceptor { /* ... */ }
The factory for constructing LiveAPIClient is exposed as a public function, not by making LiveAPIClient itself
public:
// Sources/NetworkingKit/APIClientFactory.swift
// Public — the creation point
public func makeAPIClient(
interceptors: [any RequestInterceptor] = []
) -> any APIClient {
LiveAPIClient(interceptors: interceptors)
}
This is the factory pattern applied at the module boundary. Callers get an
any APIClient value — they never import LiveAPIClient or know it exists.
Tip: When in doubt, start
internal. Promote topubliconly when a consumer actually needs it. It’s far easier to increase visibility than to reduce it after callers have started depending on a type.
Wiring Modules into the App Target
The app target’s Package.swift equivalent is the Xcode project itself. Add each local package as a project dependency,
then link the app target to the feature modules:
In Xcode: Project → App target → General → Frameworks, Libraries, and Embedded Content → + → Add Other → Add Package
Dependency → add each Packages/ subdirectory.
The app target becomes a thin orchestration layer. AppDelegate or the @main entry point is the only place where
concrete types from NetworkingKit are referenced:
// PixarApp/PixarApp.swift
import SwiftUI
import FilmsFeature
import CharactersFeature
import NetworkingKit
@main
struct PixarApp: App {
// The composition root: all concrete types are constructed here
private let tokenStore = TokenStore()
private let apiClient: any APIClient
init() {
apiClient = makeAPIClient(
interceptors: [
AuthInterceptor(tokenStore: tokenStore),
LoggingInterceptor()
]
)
}
var body: some Scene {
WindowGroup {
// Inject the abstract protocol into feature modules
FilmsRootView(client: apiClient)
}
}
}
Feature modules receive the any APIClient protocol via their root view initializer. They never import NetworkingKit
directly — they depend only on the protocol type, which is defined in NetworkingKit but passed in at the boundary:
// Sources/FilmsFeature/FilmsRootView.swift
import SwiftUI
import NetworkingKit // FilmsFeature does depend on NetworkingKit for the protocol type
import SharedModels
public struct FilmsRootView: View {
private let client: any APIClient
public init(client: any APIClient) {
self.client = client
}
public var body: some View {
NavigationStack {
FilmsListView(viewModel: FilmsViewModel(client: client))
}
}
}
Testing in Isolation
Each package has its own test target. Tests in FilmsFeatureTests never import the app target — they have no access to
app-level code, app delegates, or launch arguments. This isolation is the primary testing benefit of modular
architecture.
// Tests/FilmsFeatureTests/FilmsViewModelTests.swift
import Testing
import NetworkingKit
@testable import FilmsFeature
@Suite("FilmsViewModel")
struct FilmsViewModelTests {
@Test("sorts films by release year descending")
func sortsByYear() async throws {
let mock = MockAPIClient()
mock.stub([PixarFilm].self, with: [
PixarFilm(id: "coco", title: "Coco", year: 2017),
PixarFilm(id: "up", title: "Up", year: 2009),
PixarFilm(id: "soul", title: "Soul", year: 2020)
])
let viewModel = await FilmsViewModel(client: mock)
await viewModel.loadFilms()
let titles = await viewModel.films.map(\.title)
#expect(titles == ["Soul", "Coco", "Up"])
}
}
Note:
@testable importworks across package boundaries. You can accessinternaltypes inFilmsFeaturefromFilmsFeatureTestsbecause the test target listsFilmsFeatureas a dependency with@testable.
Because NetworkingKit has no app dependencies, you can run its tests independently with swift test from the terminal
— no Xcode required, no simulator, no provisioning profile:
cd Packages/NetworkingKit
swift test
This is the key enabler for fast CI: each module’s tests run in parallel, without building the entire app.
SwiftUI Previews in Feature Packages
Previews inside feature packages work identically to previews in the app target. The #Preview macro is available in
any Swift file that imports SwiftUI:
// Sources/FilmsFeature/FilmCardView.swift
import SwiftUI
import SharedModels
struct FilmCardView: View {
let film: PixarFilm
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(film.title)
.font(.headline)
Text(String(film.year))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
#Preview("Film Card — Toy Story") {
FilmCardView(film: PixarFilm(id: "toy-story", title: "Toy Story", year: 1995))
.padding()
}
#Preview("Film Card — Soul") {
FilmCardView(film: PixarFilm(id: "soul", title: "Soul", year: 2020))
.padding()
}
Previews in feature packages render significantly faster than previews in the main app target because the preview only
rebuilds FilmsFeature and its dependencies — not the entire app.
Apple Docs:
#Preview— SwiftUI
Advanced Usage
Remote Packages for Truly Shared Code
When a package needs to be shared across multiple repositories — a design system used by your main app and a companion watchOS app in a separate repo — promote it from a local to a remote package. Add a Git tag and reference it by URL and version:
.package(
url: "https://github.com/pixar-mobile/DesignSystem",
from: "1.2.0"
)
Remote packages are resolved once and cached in ~/.swiftpm. The tradeoff: local changes require publishing a new tag.
Local packages are better for active development; remote packages are better for stable, versioned APIs.
Binary Targets for Pre-Compiled Frameworks
If a dependency has a long compilation time (a complex parser, a third-party analytics SDK), you can distribute it as a
pre-compiled XCFramework using a binary target:
.binaryTarget(
name: "PixarAnalyticsSDK",
url: "https://cdn.pixar.com/sdk/analytics-2.1.0.xcframework.zip",
checksum: "a3f8b2c..."
)
Binary targets skip compilation entirely. The tradeoff is that they’re opaque — you can’t step into their source code in the debugger, and they must be recompiled by the vendor for each new Swift ABI.
@_exported import for Convenience Re-exports
If FilmsFeature deeply depends on SharedModels and you want consumers of FilmsFeature to automatically get
SharedModels types without an explicit import:
// Sources/FilmsFeature/Exports.swift
@_exported import SharedModels
Warning:
@_exportedis an underscored (unofficial) attribute and its behavior may change between Swift versions. Use it sparingly and document it clearly. The explicit import approach — requiring callers toimport SharedModelsthemselves — is more explicit and less surprising.
CI/CD: Parallel Module Testing
In CI, the modular structure enables testing each package in parallel. A GitHub Actions workflow example:
jobs:
test-networking-kit:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: cd Packages/NetworkingKit && swift test
test-films-feature:
runs-on: macos-15
needs: [test-networking-kit]
steps:
- uses: actions/checkout@v4
- run: cd Packages/FilmsFeature && swift test
Feature module tests run after their dependencies pass. The full integration test (building the app target) only runs after all module tests pass. Total CI time drops because jobs run in parallel on separate machines rather than sequentially on one.
The Micro-Module Anti-Pattern
You can go too far. Splitting every Swift file into its own package creates more overhead than value:
- SPM’s module resolution has a fixed startup cost. Twenty modules with two files each is slower to resolve than five modules with eight files each.
- Cross-module type references require
publicaccess on every shared type, which floods your codebase withpublicdeclarations that weren’t meant to be API. - Debugging across many module boundaries is noisier — more frames in the stack trace reference module initialization.
A good heuristic: a module should contain a cohesive set of related functionality that a developer can describe in one sentence. “NetworkingKit handles all HTTP communication” — one sentence, coherent module. “StringUtilities contains various helper functions” — vague, likely a sign of over-splitting.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| App under ~30K lines of code | Stay monolithic; the overhead isn’t justified yet |
| 3+ developers on the same codebase | Extract shared libraries first — they pay off immediately via reduced merge conflicts |
| Build times exceeding 2 minutes | Profile with xcodebuild -showBuildTimingSummary, then extract the slowest-compiling subsystem |
| Need to share code with a widget/app extension | Modularization is non-optional; extensions can’t link the main app target |
| Tight deadline / MVP | Monolith now, modularize after launch — but keep your dependency graph clean from day one |
| Micro-service-style per-feature packages | Watch for the micro-module anti-pattern; cohesion beats granularity |
| Clean Architecture (domain + data + presentation layers per feature) | Feature packages map naturally to clean architecture slices |
Summary
- A monolithic target has no enforced module boundaries — circular dependencies and slow build times are inevitable consequences at scale, not accidents.
- The module hierarchy has one rule: feature modules depend on shared libraries, never on each other. Enforce it in
Package.swiftby only declaring legal dependencies. Package.swiftmanifests are the source of truth for a module’s API surface — onlypublicdeclarations cross package boundaries, giving you compiler-enforced architecture.- Access control discipline means starting everything
internaland promoting topubliconly when a consumer needs it. ThemakeAPIClient()factory pattern avoids exposing concrete types. - Local packages with
swift testenable fast, isolated CI that tests each module independently and in parallel. - SwiftUI
#Previewmacros in feature packages render faster because they only rebuild the package and its dependencies — not the entire app. - The micro-module anti-pattern is real: split by cohesion, not by file count.
Once your modules are cleanly separated, the next step is managing how they find each other at runtime. Dependency Injection in Swift covers how to wire concrete types through a composition root without coupling your feature modules to specific implementations.