Est.

Modular iOS Architecture With Swift Package Manager

Staff Writer · · 14 min read
Swift Architecture · September 9, 2026 · 14 min read · 3,045 words

Picture a codebase with 200-plus Swift files sitting in a single Xcode target, and three engineering workstreams (payments, design system, and auth) all committing against it in the same week. That's the starting condition for most large iOS apps, and it's also where modular architecture with Swift Package Manager stops being a nice-to-have and becomes the thing standing between a team and chaos. This piece is about how to draw those module boundaries on purpose, enforce clean APIs between them, and stage the migration so a team doesn't have to choose between fixing the architecture and shipping features.

The failure mode is familiar to anyone who's worked in a big shared target. It isn't just slow, it's unpredictable: a one-line change in a networking helper breaks a screen three teams don't think of as related, and nobody finds out until CI turns red twenty minutes later. The real cost isn't compile time, either. It's that developers start serializing their work to avoid touching the same files, which defeats the entire point of having a team. And because UI wiring, feature logic, and CI configuration are all tangled together, the feedback loop doesn't grow linearly with the codebase. It grows exponentially. Modularization solves this, but only when someone actually designs the boundaries. Moving files into packages without discipline just moves the coupling problem to a new address.

What Swift Package Manager actually is and what changed when Xcode 11 adopted it

SPM shipped with Swift 3.0 in 2015, when Swift was first open-sourced, and for its first few years it lived almost entirely in server-side Swift. It didn't reach iOS developers until Xcode 11 and Swift 5, which is the point where most of the native app ecosystem started paying attention to it.

Its first-party status is worth pausing on, because it's rare in this industry. Apple has run nine WWDC sessions on SPM between 2018 and 2025, the tool has its own section on developer.apple.com, and the Swift Package Index currently lists more than 10,000 packages. No third-party dependency manager for iOS has that kind of institutional backing, and that matters for a simple reason: Apple isn't going to deprecate its own build infrastructure.

Mechanically, SPM runs on a Package.swift manifest, semantic versioning for external dependencies, and local packages that live inside a project and act as first-class modules. The shift from CocoaPods and Carthage is bigger than it looks on paper. Dependency resolution happens inside the build system itself, not as a separate step that runs before Xcode opens. There's no lockfile ceremony to babysit and no.xcworkspace file bolted on by a third-party tool. The ecosystem around SPM has also grown past pure dependency management: plugins like SwiftGen can generate localized strings, image assets, and color sets scoped to the exact package that owns them, which keeps resources from leaking across module lines the way they used to in one big asset catalog.

One limit needs to be said plainly, because it trips people up: SPM cannot build an iOS app target. The app itself still needs an.xcodeproj. SPM governs the libraries and modules that project composes, not the app bundle itself.

Apple seems aware the split causes friction. In February 2025, Apple open-sourced Swift Build, the internal engine that actually powers Xcode's compilation process, and said directly that "having two different ways to build packages has also led to user confusion." That's a rare admission from Apple, and it signals the company is working to close the gap between SPM-as-package-manager and Xcode-as-app-builder rather than leaving the two permanently separate.

How to draw module boundaries that actually reduce coupling

The most common mistake in a modularization effort isn't skipping it, it's doing it halfway. Teams move code into packages, then mark too many symbols public to make reuse easier, and end up with the exact same coupling problem, just now enforced by a package boundary instead of a folder. The fix isn't more packages. It's fewer public symbols.

A three-layer clean architecture maps onto SPM targets almost without modification. A Data layer handles networking, database access, and raw data manipulation. A Domain layer holds business logic, entities, and use cases. A Presentation layer owns UI components and user interaction. Feature modules sit above all three: a search screen, a favorites flow, a feed. Each one is a sovereign unit that can depend downward on the layers beneath it, but never sideways on a peer feature module. If a feed feature needs something from a favorites feature, that's a sign the shared logic belongs in Domain, not that the two features need to know about each other.

Protocol-Oriented Programming is what makes the contract between modules actually hold. A module should expose protocols, not concrete types. Callers depend on an interface, never on an implementation, which means the implementation can change entirely without anyone downstream noticing or caring. Swift's own access control backs this up directly: internal by default, public only for what's genuinely part of the module's intended contract, and nothing that leaks an implementation detail a caller shouldn't know about.

There's a useful test for whether a boundary is actually well drawn. Can a developer understand and modify a feature module without reading a single file outside it? If the answer is no, the boundary is wrong, full stop, no matter how clean the folder structure looks in Xcode's navigator.

A workable project layout, drawn from how professional teams tend to structure this: an App (or iOSApp) target at the top, and a Packages directory underneath holding Core, Networking, DesignSystem, UserFeature, and FeedFeature as independent packages, with the App target composing all of them together. None of the feature packages know about each other. Only the App target knows about everyone.

This isn't theoretical. At Swift Heroes 2024, Libranner Santos, a domain architect at Kindred Group, described a distributed team of 15-plus engineers reaching this exact structure over roughly two years, using SPM, SwiftUI, and MVVM. The payoff wasn't primarily build speed. It was developer velocity: people could ship features without stepping on each other, which is the actual problem modularization is meant to solve.

Mapping architecture patterns to modules: MVVM, Coordinators, and async/await in a multi-package world

MVVM is the dominant pattern for feature modules right now, and for a specific structural reason: the ViewModel owns business-facing state, the View owns rendering, and that split maps cleanly onto SPM targets. The ViewModel can live in a domain-adjacent package, the View in a presentation package, and the two never need to know about each other's internal wiring.

Navigation is the part people get wrong first. MVVM-C, MVVM with a Coordinator layered on top, solves the cross-module routing problem by keeping coordinators at the app composition layer, not inside feature modules. A feature module exposes a factory method or a view builder. It never contains routing logic that points at another feature, because that would recreate the sideways dependency the whole architecture is designed to avoid.

Async/await has quietly become the concurrency contract that makes inter-module interfaces bearable. Callback pyramids used to make it genuinely hard to reason about what crossed a module boundary and when; async/await collapses that into code that reads top to bottom, which matters even more once the caller and the callee live in separate packages maintained by separate teams.

SwiftUI reinforces the same discipline by its nature. Views are value types composed from smaller views, so a DesignSystem package can export primitive components, a feature package composes those primitives into a screen, and the app target assembles the features. Because SwiftUI views and packages are independently distributable SPM modules, a DesignSystem package can be versioned and shared across multiple apps or internal tools, not just used inside one.

One rule worth holding firm on: a module's ViewModel should never cross the module boundary. Expose what the caller actually needs through a protocol and keep the ViewModel itself internal. And the iOS 18-and-later landscape makes this discipline more urgent, not less. Apple Intelligence integrations, Liquid Glass UI, and feature-flag-driven releases all depend on the ability to toggle a capability on or off without rebuilding the entire app, which is only possible if that capability already lives in a module that can be composed in or left out.

Where SPM's scaling limits appear and what teams do when they hit them

SPM handles a modest package count without complaint. The friction shows up as the dependency graph grows, and it shows up in specific, measurable ways rather than vague slowness.

An engineer working on Amazon Flex reported build time increases of 40 to 50 minutes tied directly to transitive dependency resolution, in a project carrying more than 100 packages. Qonto's engineering team has documented a separate set of frustrations at the Xcode level: intermittent problems when switching git branches, weak support for structures like a demo project living inside a module, and automation for creating new modules that's more complicated than it should be. As of March 2025, SPM's GitHub repository shows 10,100 stars against 1,045 open issues, and that ratio lines up with the scaling complaints teams describe: known, documented, and still unresolved upstream.

The underlying constraint doesn't go away no matter how the tooling improves around it. SPM manages libraries, not app targets, so teams running complex multi-scheme builds, selective test configurations, or a demo app for every module need something layered on top.

Two tools have emerged to cover different parts of that gap. Tuist handles project generation, build caching, and test configuration, all defined in Swift rather than YAML or JSON, so engineers use the same language they already write features in. Bazel goes further, offering remote execution and hermetic builds at a scale few consumer apps ever reach; its GitHub numbers, 25,200 stars against 1,624 open issues, reflect a tool with a high ceiling and a correspondingly high setup cost. Tuist's own numbers as of March 2025, 5,573 stars against 197 open issues, suggest a smaller but more tightly maintained project relative to its size. Qonto's team picked Tuist over XcodeGen for a plain reason: defining projects in Swift removes the cost of learning an entirely separate configuration language just to describe how a project is built.

What Tuist changes in practice: Delivery Hero's module growth and the SmartNews migration

Delivery Hero's Rider app is a useful before-and-after. In 2022 the app carried 25 modules; by 2026 that number had grown to roughly 115. Before Tuist entered the picture, a local clean build took 2.5 minutes and an incremental build took 0.5 minutes, numbers that would have gotten steadily worse as the module count kept climbing. After adopting Tuist, CI build and unit test execution time dropped by a factor of 2.5, and project maintenance became noticeably simpler to manage day to day. The real story isn't the speed number. It's that going from 25 modules to 115 was operationally survivable at all. Tuist is what made continued splitting feasible without the project configuration collapsing under its own weight.

SmartNews presented a harder case at Swift Tokyo 2026: a 14-year-old iOS application carrying more than 500,000 lines of mixed Objective-C and Swift, with more than 60 external dependencies and more than 70 internal modules. The goal was to migrate off CocoaPods entirely, onto SPM and Tuist, ahead of the CocoaPods sunset in 2026, and to do it without stopping feature delivery for even a sprint. Two engineers on the foundation team carried the migration, and they completed what the team called a zero-downtime transition in six months.

That's the case worth remembering for anyone inheriting a legacy codebase and assuming modularization is a multi-year moonshot. It's solvable with a small dedicated team and a staged plan, at genuine scale, on a genuinely old codebase.

What both companies share matters as much as either result on its own. Neither team replaced SPM with Tuist. They layered Tuist on top, specifically to handle the things SPM was never built to do: project generation, build caching, CI configuration.

Testing strategy in a modular codebase: scoping tests to packages and running them in parallel

Modularization's clearest testing benefit is isolation. Each package can run its own test suite without building the entire app, so only the module that actually changed, plus whatever depends on it, needs to rebuild before tests run.

Package-scoped tests do more than save build time, though. They enforce the module contract directly. If a test needs to reach across a package boundary to pass, that's not a testing gap, it's a design signal that the boundary itself is drawn wrong.

Halodoc's Tuist configuration is a solid concrete example of what this unlocks. The team set isParallelizable: true for every SDK test target in the testAction, which tells Xcode to run tests concurrently across multiple simulators in Debug mode, with code coverage turned on the whole time. Without module boundaries already in place, that kind of parallelism is mostly theoretical, because an unmodularized app is one giant compilation unit and its tests share state in ways that make running them concurrently unsafe. Modularization isn't an optimization on top of parallel testing. It's the precondition for it.

There's a second benefit that shows up less often in these conversations: package-scoped tests that compile only against a module's public interface catch API-stability regressions early. If a refactor accidentally changes a public symbol, the tests that only see the public surface will fail immediately, before that change has a chance to propagate into three other modules that depended on the old shape.

How to stage a modularization migration without stopping feature delivery

SmartNews's six-month timeline, with two engineers covering 60-plus external dependencies and 70-plus internal modules at zero downtime, isn't a best-case outcome to admire from a distance. It's a repeatable sequence, and it breaks down into stages that apply regardless of a codebase's specific size.

Start with the lowest-dependency code. Utilities, the design system, and networking code typically have no dependencies on any feature, which makes them the safest things to extract first and the foundation everything else will eventually sit on.

Next, define the internal API before moving a single line of implementation. Write the protocols that will become the module's public contract, have the existing code inside the monolith start depending on those protocols instead of concrete types, and only then move the implementation into its own package. Done this way, callers never notice the extraction happened.

Then tackle feature modules one at a time, and use the actual dependency graph, not intuition, to pick the order. Features with the fewest inbound references are the safest to pull out first, because there's less that can break if the extraction goes wrong.

Access control discipline matters more during migration than at almost any other point. It's tempting to mark a symbol public just to make the compiler stop complaining, but every public symbol added under time pressure becomes permanent technical debt sitting on the module's API surface. And migrations need observability of their own: tie package releases to CI signals directly, so if an extraction causes build time or test failure rates to creep up, the team catches it at the package boundary instead of after it's already spread into three other modules.

The CocoaPods sunset in 2026 gives teams still on that tool an external deadline that SmartNews already had to meet. The staged approach doesn't change based on why a team is migrating, whether the trigger is accumulated technical debt, team growth outpacing the old architecture, or a dependency manager reaching end of life. For teams still early in this journey, the Swift Heroes 2024 talk is worth revisiting for a different reason: it documents the mistakes a 15-plus person distributed team made building toward MVVM, SwiftUI, and SPM from the start, and getting the structure right early is consistently cheaper than migrating into it after the fact.

What modular boundaries enable when LLMs enter the native iOS layer

The connection between modular architecture and on-device AI is structural, not incidental. Apple's AI capabilities in iOS 26, Foundation Models and Core AI, arrive as framework-level additions that slot naturally into the feature module layer. A module with well-drawn boundaries can adopt them without any other part of the app needing to know or care.

Apple's Foundation Models framework, introduced at WWDC 2025 and expanded in iOS 26, gives developers a Swift-native way to call the on-device LLM in as few as three lines of code. Its @Generable macro handles guided generation, which forces the model's output into a type-safe shape rather than a raw string a caller has to parse and hope for the best.

Core AI, new in 2026, is built for teams that want to run their own models rather than rely on Apple's, supporting both custom-converted models and pre-optimized open-source ones, tuned specifically for the unified memory architecture and Neural Engine that Apple silicon provides.

The scale difference here is worth being precise about, because it's easy to undersell. Apple's on-device model runs at a few hundred megabytes and loads in seconds using the device's Metal GPU and Neural Engine. Cloud models like ChatGPT or Gemini need entire GPU clusters to serve a single request. The on-device model isn't a smaller version of a cloud LLM. It's a different category of tool, built around different tradeoffs entirely.

Modular architecture is what makes this practical rather than messy. An AI feature module can wrap the Foundation Models session, its tool definitions, and its @Generable types entirely behind a protocol, so the rest of the app only ever calls that protocol and never touches the framework directly. Swapping the underlying model, or shipping a new version of the AI capability, doesn't require touching a single line of feature code anywhere else in the app.

Xcode 27 goes a step further and brings coding agents from Anthropic, Google, and OpenAI directly into the development workflow itself, which means the tooling layer is becoming AI-native at the same time the app layer is. Mobile AI agency is still an emerging specialty, and the engineers positioned to own it won't be the ones bolting AI features onto whatever architecture already exists. They'll be the ones who already built the modular infrastructure that makes a new AI capability something you compose into the app, not something you wedge in.

Sources

  1. A Tale of Modular Architecture with SPM Swift Package Manager | Swift Heroes 2024 Talk - YouTube
  2. speakerdeck.com
  3. deliveryhero.jobs
  4. blogs.halodoc.io

More in Swift Architecture