← Back to writing

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:

  1. Feature modules: The screens and flows a user sees, for example GalleryFeature, SettingsFeature. They should not depend on one another directly.
  2. Core / infrastructure modules: Technical capabilities such as networking, persistence, logging, and analytics. Features consume them.
  3. 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