Vedran Burojevic
← WritingJuly 5, 2026

Modular iOS apps with Swift Package Manager: real boundaries, real build times

How to break an iOS app into Swift Package modules that actually improve build times and team velocity, without over-engineering the dependency graph.

On this page

A modular iOS app is not automatically a better iOS app.

Swift Package Manager makes it easy to split code into packages and targets. That is useful. It also makes it easy to create a dependency graph that looks sophisticated while making every local change slower, every test run more awkward, and every feature harder to move.

Modularity should buy something concrete:

  • smaller rebuilds
  • clearer ownership
  • faster tests
  • safer boundaries
  • easier reuse across app extensions, widgets, previews, and internal tools

If the split does not improve at least one of those, it may just be architecture furniture. Polished, heavy, and always in the way.

1. Start from pain, not taste

Do not modularize because the codebase feels large.

Large is not a diagnosis. Large and slow is a diagnosis. Large and impossible to test is a diagnosis. Large where every feature imports persistence, networking, analytics, and half the app shell is a diagnosis.

Before cutting packages, identify the pain you are trying to reduce:

  1. Does changing one feature rebuild too much of the app?
  2. Are tests slow because they have to load the full application target?
  3. Are shared components tightly coupled to product-specific state?
  4. Do app extensions or widgets duplicate code because the main app target owns everything?
  5. Can teams work independently without stepping on the same files?
  6. Can you reason about dependencies without opening a map and whispering a prayer?

The answer determines the module shape.

A team with build-time pain needs fewer invalidation paths. A team with testing pain needs domain and feature logic outside the app target. A team shipping widgets needs shared models and services that do not import UIKit assumptions from the main app.

Same tool. Different problem. Different cut.

2. Keep the app target thin

The app target should assemble the product. It should not contain the product.

A thin app target usually owns:

  • app entry point
  • scene setup
  • dependency composition
  • top-level routing
  • app-wide configuration
  • platform integrations that only make sense at the shell

Most product logic should live elsewhere.

A practical shape:

App
├── AppShell
│   ├── routing
│   ├── dependency composition
│   └── scene setup
├── Features
│   ├── OnboardingFeature
│   ├── AccountFeature
│   ├── CheckoutFeature
│   └── SettingsFeature
├── Core
│   ├── Networking
│   ├── Persistence
│   ├── DesignSystem
│   ├── Analytics
│   └── Auth
└── TestSupport
    ├── fixtures
    ├── stubs
    └── test builders

The exact names do not matter. The direction does.

Feature modules should depend on stable lower-level modules. Lower-level modules should not know about feature modules. The app shell may depend on everything because it composes everything, but that privilege should not leak back down.

If Networking imports CheckoutFeature, something has gone wrong. If DesignSystem knows about subscription plans, something has gone wrong in a more stylish outfit.

3. Design boundaries around reasons to change

A module boundary is useful when code on each side changes for different reasons.

Good boundaries often form around:

  • a product feature with its own screens, state, and workflow
  • a platform service used by multiple features
  • a design system with stable UI primitives
  • domain logic that can be tested without SwiftUI
  • app-extension-safe code shared with widgets or intents
  • test support that should not ship in production

Weak boundaries form around file categories:

  • Views
  • ViewModels
  • Helpers
  • Managers
  • Utilities

Those folders can be useful inside a module. As modules, they usually create horizontal coupling. Now every feature imports Views, ViewModels, and Managers, and the graph tells you nothing except that the codebase owns nouns.

Prefer vertical feature modules and narrow shared foundations.

For example, CheckoutFeature can own its SwiftUI screens, workflow state, validation, and feature-specific copy. It can depend on PaymentsClient, EntitlementsClient, DesignSystem, and AnalyticsClient protocols from lower modules. It should not expose its internals to the rest of the app because another screen wants one button style.

That is the point of the boundary: reduce what other code is allowed to know.

4. Make dependencies point one way

The dependency graph should be boring enough to explain in one minute.

A common direction:

AppShell
  → Feature modules
    → Core clients and domain modules
      → Foundation-level utilities

Avoid cycles. Swift Package Manager will block direct target cycles, but architectural cycles still happen through protocols, callbacks, environment objects, and shared global state.

The usual traps:

  1. A core module imports app-specific models.
  2. A shared UI module imports feature-specific state.
  3. A feature imports another feature to reuse one view.
  4. A networking module owns endpoint definitions for every feature.
  5. A Core package becomes a landfill because nobody wants to choose a smaller boundary.

Core is dangerous because the name sounds responsible. Without discipline, it becomes the junk drawer with better branding.

If a dependency is only needed by one feature, keep it there. If two features need the same concept, extract the smallest shared contract first, not the entire implementation.

5. Use package manifests as architecture documentation

A Package.swift file is not just build configuration. It is executable architecture.

Keep it readable.

// Package.swift
let package = Package(
    name: "AppModules",
    platforms: [.iOS(.v17)],
    products: [
        .library(name: "CheckoutFeature", targets: ["CheckoutFeature"]),
        .library(name: "DesignSystem", targets: ["DesignSystem"]),
        .library(name: "Networking", targets: ["Networking"]),
        .library(name: "TestSupport", targets: ["TestSupport"]),
    ],
    targets: [
        .target(
            name: "CheckoutFeature",
            dependencies: [
                "DesignSystem",
                "Networking",
                "AnalyticsClient",
            ]
        ),
        .target(name: "DesignSystem"),
        .target(name: "Networking"),
        .target(
            name: "TestSupport",
            dependencies: ["Networking"]
        ),
        .testTarget(
            name: "CheckoutFeatureTests",
            dependencies: ["CheckoutFeature", "TestSupport"]
        ),
    ]
)

When the manifest grows, it should still tell a clear story:

  • which targets are public products
  • which targets are implementation details
  • which features depend on which services
  • which test helpers are test-only
  • which external packages are contained behind internal boundaries

Do not expose every target as a product because it is convenient. Products are what other packages and the app can import. Targets that are only implementation details should stay internal to the package where possible.

The fewer public doors you create, the fewer hallways you have to patrol later.

6. Hide external dependencies behind small clients

Third-party packages should not leak everywhere.

If a feature needs image loading, analytics, payments, logging, charts, or networking, resist the easy path of importing that package directly in every screen that touches it.

Wrap the dependency behind a small app-owned boundary:

public protocol AnalyticsClient: Sendable {
    func track(_ event: AnalyticsEvent)
}
 
public struct AnalyticsEvent: Sendable, Equatable {
    public let name: String
    public let properties: [String: String]
}

Now features depend on AnalyticsClient, not on the vendor SDK. The app shell can provide the live implementation. Tests can provide a recorder. If the SDK changes, the blast radius stays small.

This matters for build times too. A dependency imported everywhere becomes part of every rebuild story. A dependency hidden behind one adapter target is easier to cache, replace, and reason about.

Swift Package Manager installing something politely does not make it free.

7. Separate feature API from feature implementation

For larger features, consider splitting the public interface from the implementation.

CheckoutFeatureInterface
CheckoutFeatureImplementation
CheckoutFeatureTests

The interface target can expose:

  • route definitions
  • dependency protocols
  • lightweight models needed by other modules
  • factories for constructing the feature entry point

The implementation target owns:

  • SwiftUI views
  • reducers or view models
  • workflow logic
  • private helpers
  • feature-specific dependencies

This is not necessary for every feature. Do not create ceremony for a two-screen settings flow.

It becomes useful when:

  1. other modules need to navigate to the feature without importing all of it
  2. the feature has heavy dependencies
  3. tests need fast access to contracts
  4. the app shell should compose features without seeing internals

The point is not abstraction theater. The point is to make import relationships honest.

8. Keep shared UI small and semantic

A design system module should not become a dumping ground for every reusable-looking view.

Good shared UI contains stable primitives and product-wide patterns:

  • colors, typography, spacing, and shape tokens
  • button styles
  • text field styles
  • loading and empty-state components
  • common containers
  • accessibility helpers

Weak shared UI contains feature-specific cards, rows, and panels promoted too early because two screens looked similar during a sprint.

A reusable component needs a stable job, not just repeated pixels.

If SubscriptionPlanCard lives in DesignSystem, every product change to subscription logic now touches the shared UI module. That is usually wrong. Keep it inside CheckoutFeature until another feature truly needs the same product pattern with the same state model.

Shared modules should be boring. Boring shared code changes slowly, which is exactly why it helps build performance.

9. Use modules to make tests cheaper

One of the best reasons to modularize is test speed.

A feature module can test workflow logic, validation, formatting, routing, and client interaction without launching the app target or booting a simulator.

@Test
func submittingValidCheckoutTracksPurchaseAttempt() async throws {
    let analytics = AnalyticsRecorder()
    let payments = PaymentsClient.succeeding()
    let model = CheckoutModel(
        payments: payments,
        analytics: analytics
    )
 
    try await model.submit()
 
    #expect(analytics.events.contains(.checkoutSubmitted))
}

That kind of test should run in milliseconds. If a normal feature test requires the full app target, real network configuration, live persistence, and a simulator, the module boundary is not helping enough.

Create TestSupport targets deliberately:

  • fixture builders
  • fake clients
  • in-memory stores
  • deterministic clocks
  • route builders
  • sample domain objects

Keep those targets out of production products. Test helpers leaking into app code are how convenience becomes a dependency smell with a tiny mustache.

10. Measure build impact instead of assuming it

Modularization should improve build behavior. Verify that it did.

Measure before and after:

  1. Clean build time on a normal development machine.
  2. Incremental build after editing one feature view.
  3. Incremental build after editing shared UI.
  4. Test time for one feature module.
  5. CI build time with and without cache hits.
  6. Package resolution time.

The most important number is usually incremental build time for common edits. Developers feel that delay dozens of times per day.

A bad module split can make clean builds worse and incremental builds no better. More targets mean more settings, more indexing, more package resolution, and more ways for Xcode to express itself emotionally.

The goal is not the most modules. The goal is the smallest rebuild surface for normal work.

11. Watch for package sprawl

Swift Package Manager makes new targets cheap. Cheap is not the same as free.

Signs the graph has gone too far:

  • nobody knows which module owns a new type
  • features depend on six tiny helper modules to render one screen
  • moving a type requires editing many manifests
  • local previews fail because dependencies are hard to assemble
  • CI spends too much time resolving packages
  • every boundary needs a protocol, factory, adapter, and ceremony committee

When this happens, merge modules that change together. Boundaries should earn rent.

A useful review question: if these two modules always change in the same pull request, why are they separate?

Sometimes the answer is good: one is app-extension-safe, one is not. One has a heavy dependency, one does not. One is shared across products, one is private. Fine.

If the answer is "because the architecture diagram looked cleaner," the diagram can survive a little disappointment.

12. A practical baseline

For most growing iOS apps, I would start with this:

  1. Keep the app target thin and composition-focused.
  2. Extract stable foundations: networking, persistence, analytics, auth, logging, and design system.
  3. Create vertical feature modules for workflows with real screens, state, and tests.
  4. Keep external SDKs behind app-owned clients.
  5. Add test support targets so feature tests stay fast.
  6. Avoid making every target a public product.
  7. Measure incremental build time before and after the split.
  8. Merge modules that do not reduce coupling, ownership confusion, or build cost.

Swift Package Manager is a good tool for modular iOS architecture. It is not a magic architecture generator.

Good modularity makes the codebase quieter. Edits rebuild less. Tests run closer to the code they prove. Dependencies point in one direction. Teams can work without dragging the whole app through every small change.

That is the bar. If the module graph cannot explain how it helps build time, ownership, or testing, it is not architecture yet. It is just more places for the same mess to hide.

Command menu

Navigate the site or run an action