Monorepo with Swift: Code Organization in Practice
\n\nModern iOS development increasingly faces the challenge of scaling a codebase. When a project grows to dozens of modules, shared libraries, and multiple applications (iOS, macOS, watchOS), the traditional model with separate repositories (polyrepo) begins to slow down development. A monorepo comes to the rescue — a single repository containing the code of all ecosystem components. In this article, we will practically explore how to organize a monorepo for Swift projects using modern tools: Swift Package Manager (SPM), Tuist, and efficient CI/CD pipelines.
\n\n1. Why Do You Need a Monorepo in the Swift Ecosystem?
\n\nBefore diving into implementation, it's important to understand the key advantages of this approach:
\n\n- \n
- Unified versioning — all modules and applications are in one commit, eliminating the "dependency hell" problem. \n
- Simplified refactoring — changing an API in a library is immediately visible to all consumers; the compiler highlights errors within a single PR. \n
- Transparent navigation — developers see the entire project code without switching between repositories. \n
- CI/CD optimization — you can configure builds for only changed modules (targeted builds), saving time and resources. \n
However, a monorepo requires discipline: a clear modular architecture and tools to prevent chaos. For Swift, the best choice today is Tuist — a utility that manages Xcode projects declaratively, or pure Swift Package Manager for a modular structure.
\n\n2. Monorepo Structure with Swift Package Manager
\n\nLet's start with a basic organization. Suppose we have two applications: MyAppiOS and MyAppMacOS, as well as a set of internal libraries. The file structure might look like this:
Monorepo/\n├── Apps/\n│ ├── MyAppiOS/\n│ │ ├── Sources/\n│ │ └── Package.swift (or project.yml for Tuist)\n│ └── MyAppMacOS/\n│ ├── Sources/\n│ └── Package.swift\n├── Libraries/\n│ ├── Networking/\n│ │ ├── Sources/\n│ │ └── Package.swift\n│ ├── UIComponents/\n│ │ ├── Sources/\n│ │ └── Package.swift\n│ └── Core/\n│ ├── Sources/\n│ └── Package.swift\n├── Tests/\n│ ├── NetworkingTests/\n│ └── UIComponentsTests/\n├── Package.swift (root manifest)\n└── Tuist/ (if using Tuist)\n └── Project.swift\n\n\nThe root Package.swift ties everything together. Example manifest:
// swift-tools-version:5.9\nimport PackageDescription\n\nlet package = Package(\n name: "MyMonorepo",\n platforms: [\n .iOS(.v16), .macOS(.v13)\n ],\n products: [\n .library(name: "Networking", targets: ["Networking"]),\n .library(name: "UIComponents", targets: ["UIComponents"]),\n .library(name: "Core", targets: ["Core"]),\n .executable(name: "MyAppiOS", targets: ["MyAppiOS"]),\n .executable(name: "MyAppMacOS", targets: ["MyAppMacOS"])\n ],\n dependencies: [\n // External dependencies\n .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0")\n ],\n targets: [\n .target(\n name: "Networking",\n dependencies: ["Core", .product(name: "Alamofire", package: "Alamofire")],\n path: "Libraries/Networking/Sources"\n ),\n .target(\n name: "UIComponents",\n dependencies: ["Core"],\n path: "Libraries/UIComponents/Sources"\n ),\n .target(\n name: "Core",\n path: "Libraries/Core/Sources"\n ),\n .executableTarget(\n name: "MyAppiOS",\n dependencies: ["Networking", "UIComponents"],\n path: "Apps/MyAppiOS/Sources"\n