Scalable Modular Architecture in iOS
Practical rules for breaking a single-target iOS app into independent modules: SPM structure, dependency direction, and the traps you hit at scale.
1. Introduction: Why Modularity?
An application that grows inside a single Xcode target eventually starts working against the developer. Build times climb, a small change triggers a recompilation of the whole project, teams collide on the same files, and testability degrades. Modular architecture solves this by splitting the app into pieces that can be built, tested, and developed independently.
The concrete benefits of modularity are:
- Build time: Only the changed module and its dependents recompile. The incremental build gain is measured in minutes on large projects.
- Team scaling: Different teams work in parallel across different modules, reducing merge conflicts.
- Testability: Testing a module in isolation is far faster than booting the entire app.
- Reusability: A design system or networking module can be shared across multiple apps.
- Boundary clarity: Module boundaries let the compiler enforce architectural decisions.
2. The Core Principle: Direction of Dependencies
The single most important rule of a healthy modular architecture is that dependencies flow in one direction. High level modules (features) may depend on low level modules (core, domain), but never the reverse. The domain layer must not know who uses it.
App
├── Feature: Onboarding
├── Feature: Gallery
│ ├── Domain (business rules)
│ └── DesignSystem
└── Core
├── Networking
├── Persistence
└── Logging
In this layout arrows flow top to bottom. A Core module importing a Feature module is an architectural code smell and opens the door to circular dependencies.
3. Types of Modules
In practice it helps to split modules into three main categories:
- Feature modules: The screens and flows a user sees, for example
GalleryFeature,SettingsFeature. They should not depend on one another directly. - Core / infrastructure modules: Technical capabilities such as networking, persistence, logging, and analytics. Features consume them.
- Shared modules: Design system, common models, helper extensions. They can be used by both features and core.
Avoid one common trap: a catch all Common or Utils module turns into a giant dependency dumping ground over time. Prefer small, purpose driven modules instead.
4. Modularization with Swift Package Manager
In modern iOS projects, the most practical way to separate modules is Swift Package Manager (SPM). Each module is defined as a local package. Below is a Package.swift that defines multiple products and targets within a single package.
// Package.swift
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Modules",
platforms: [.iOS(.v16)],
products: [
.library(name: "GalleryFeature", targets: ["GalleryFeature"]),
.library(name: "Networking", targets: ["Networking"]),
.library(name: "DesignSystem", targets: ["DesignSystem"])
],
targets: [
.target(
name: "GalleryFeature",
dependencies: ["Networking", "DesignSystem"]
),
.target(
name: "Networking",
dependencies: []
),
.target(
name: "DesignSystem",
dependencies: []
),
.testTarget(
name: "GalleryFeatureTests",
dependencies: ["GalleryFeature"]
)
]
)
Here GalleryFeature depends on Networking and DesignSystem. If you try to declare a dependency in the reverse direction, SPM reports a circular dependency error. This means the architectural rule is enforced by the compiler.
5. Layered Architecture: Presentation, Domain, Data
The module structure is a physical separation. Inside it you also need a logical layered structure. A common approach is three layers:
- Domain: Business rules, entities, and use cases. It depends on no framework and is pure Swift.
- Data: Repository implementations, network and database access. It implements the protocols from domain.
- Presentation: ViewModels and Views. It handles user interaction.
We define the domain layer with protocols first:
// Domain layer: pure Swift, framework independent
public struct Photo: Identifiable, Equatable {
public let id: String
public let isBlurry: Bool
public let sizeInBytes: Int
public init(id: String, isBlurry: Bool, sizeInBytes: Int) {
self.id = id
self.isBlurry = isBlurry
self.sizeInBytes = sizeInBytes
}
}
public protocol PhotoRepository {
func fetchPhotos() async throws -> [Photo]
func deletePhotos(ids: [String]) async throws
}
The use case encapsulates a business rule and depends on the repository abstraction:
public struct FindBlurryPhotosUseCase {
private let repository: PhotoRepository
public init(repository: PhotoRepository) {
self.repository = repository
}
public func execute() async throws -> [Photo] {
let all = try await repository.fetchPhotos()
return all.filter { $0.isBlurry }
}
}
Note that the use case does not know where the data comes from, whether network or local database. That is the responsibility of the data layer, and it can be swapped for a mock repository easily during testing.
6. Dependency Injection
To preserve loose coupling between modules, inject protocols rather than concrete types. Below is a simple DI container that needs no external library:
public final class DependencyContainer {
private var factories: [String: () -> Any] = [:]
public init() {}
public func register<T>(_ type: T.Type, factory: @escaping () -> T) {
factories[String(describing: type)] = factory
}
public func resolve<T>(_ type: T.Type) -> T {
let key = String(describing: type)
guard let factory = factories[key], let instance = factory() as? T else {
fatalError("No registration found for \(key)")
}
return instance
}
}
Usage:
let container = DependencyContainer()
container.register(PhotoRepository.self) {
LivePhotoRepository(client: NetworkClient())
}
let repository = container.resolve(PhotoRepository.self)
let useCase = FindBlurryPhotosUseCase(repository: repository)
This approach keeps the presentation layer from ever seeing the data implementation. The ViewModel only knows the use case.
7. Inter Module Communication
Having two feature modules import each other directly leads to tight coupling and a risk of circular dependencies. Instead, navigation and communication should happen through an abstract interface. A common solution is a router or coordinator protocol defined in a shared module.
// Defined in a shared module
public protocol GalleryRouting {
func openSettings()
func openPhotoDetail(id: String)
}
GalleryFeature depends only on this protocol. The actual routing logic is implemented in the App layer, the place that can see every module:
// In the App layer, the place that sees all features
final class AppRouter: GalleryRouting {
func openSettings() {
// Presents SettingsFeature
}
func openPhotoDetail(id: String) {
// Shows the detail screen
}
}
This way no direct build dependency forms between GalleryFeature and SettingsFeature.
8. Example Feature Module Structure
Maintaining a consistent folder structure inside a feature module makes it easier for the team to navigate:
GalleryFeature/
├── Domain/
│ ├── Photo.swift
│ ├── PhotoRepository.swift
│ └── FindBlurryPhotosUseCase.swift
├── Data/
│ └── LivePhotoRepository.swift
├── Presentation/
│ ├── GalleryViewModel.swift
│ └── GalleryView.swift
└── GalleryRouting.swift
The ViewModel receives the use case and the router via injection:
@MainActor
public final class GalleryViewModel: ObservableObject {
@Published public private(set) var blurryPhotos: [Photo] = []
@Published public private(set) var isLoading = false
private let useCase: FindBlurryPhotosUseCase
private let router: GalleryRouting
public init(useCase: FindBlurryPhotosUseCase, router: GalleryRouting) {
self.useCase = useCase
self.router = router
}
public func load() async {
isLoading = true
defer { isLoading = false }
do {
blurryPhotos = try await useCase.execute()
} catch {
blurryPhotos = []
}
}
public func openDetail(_ photo: Photo) {
router.openPhotoDetail(id: photo.id)
}
}
9. Impact on Build and CI
Modularization is not only code organization, it is also a performance strategy. The practical gains:
- Cached builds: Unchanged modules do not recompile. With a Swift Package cache on CI, build time drops considerably.
- Parallel testing: A test target per module is suited for parallel execution.
- Sample app approach: Creating a tiny demo app for each feature module lets you run that feature in isolation without booting the whole app. This speeds up the development loop.
A warning: over modularization is also harmful. Dozens of tiny modules introduce package resolution and linking costs. The healthiest practice is to draw module boundaries based on team structure and rate of change.
10. Conclusion
Scalable modular architecture is not a single technique. It is the combination of a clear dependency direction, layered separation of responsibilities, protocol based communication, and dependency injection. Applied correctly, build times fall, testability rises, and multiple developers can work without stepping on each other. The key is to draw module boundaries to reflect your architectural intent, neither too fine nor too coarse.
← Back to writing