Introduction: Why a Monorepo in Swift?
iOS and macOS development often faces a dilemma: how to organize code so that it remains scalable, reusable, and easily maintainable? The traditional approach with many separate repositories (multirepo) leads to fragmentation, dependency duplication, and difficulties with coordinating changes. The solution is a monorepo.
A monorepo is a single repository containing all project components: applications, libraries, build tools, and configurations. For the Swift ecosystem, this approach is especially relevant thanks to Swift Package Manager (SPM), which since version 5.3+ has excellent support for local packages and modular architecture.
In this article, we will break down how to organize a monorepo for Swift projects, examine the structure, tools (Tuist, XcodeGen), and best practices with code examples.
1. Advantages of a Monorepo for Swift
Before moving to implementation, let's look at the key benefits:
- Single version of dependencies — all modules use the same version of libraries, eliminating conflicts.
- Atomic commits — an API change in one package is immediately reflected in all dependent modules, without the need for synchronization between repositories.
- Simplified CI/CD — one pipeline, one configuration, build caching.
- Code reuse — common utilities, models, network layers are easily accessible for all targets (iOS, macOS, watchOS).
- Code review — changes in different parts of the project are visible in a single PR.
However, there are also downsides: repository size, cloning time, and the need for a well-thought-out structure. But for medium-sized teams and above, a monorepo often turns out to be the optimal choice.
2. Structure of a Monorepo in Swift
Let's look at a typical structure for a project that includes an iOS app and a macOS app with a shared library:
MyProject/├── Apps/│ ├── iOSApp/│ │ ├── Sources/│ │ │ └── AppDelegate.swift│ │ ├── Resources/│ │ └── Package.swift│ └── macOSApp/│ ├── Sources/│ │ └── AppDelegate.swift│ ├── Resources/│ └── Package.swift├── Packages/│ ├── Core/│ │ ├── Sources/│ │ │ ├── Core.swift│ │ │ └── Extensions/│ │ ├── Tests/│ │ └── Package.swift│ ├── Networking/│ │ ├── Sources/│ │ │ └── NetworkManager.swift│ │ ├── Tests/│ │ └── Package.swift│ └── UIComponents/│ ├── Sources/│ │ └── CustomButton.swift│ ├── Tests/│ └── Package.swift├── Tools/│ └── Scripts/│ └── build.sh├── .github/│ └── workflows/│ └── ci.yml├── Package.swift (root)├── Tuist.swift (if using Tuist)└── README.mdStructure explanation:
Apps/— directory with final applications. Each application is a separate Swift package that depends on packages fromPackages/.Packages/— all internal libraries and modules. Each package has its ownPackage.swiftand can be tested independently.Tools/— build scripts, linters, code generation.- Root
Package.swift(or Tuist/XcodeGen configuration) — describes the workspace and connections between packages.
2.1 Example of a Root Package.swift (SPM)
// swift-tools-version:5.9import PackageDescription
let package = Package( name: "MyProject", platforms: [ .iOS(.v16), .macOS(.v13) ], products: [ .library(name: "Core", targets: ["Core"]), .library(name: "Networking", targets: ["Networking"]), .library(name: "UICompon