Overview
How to modularize an iOS project into layered modules using CocoaPods Private Spec Repos.
This project was designed around 2020. Today Swift Package Manager has become the mainstream and project generation tools like Tuist are widely used, so the trend may differ from back then. However, the principles of module separation and layered architecture apply regardless of the tooling, so this is a belated write-up.
Summary
1. Architecture Overview
The project consists of four layers.
┌───────────────────────────────────────────┐
│ App │
│ (synstagram-app) │
├───────────────────────────────────────────┤
│ Scene Layer │
│ (LoginScene, ...) │
├───────────────────────────────────────────┤
│ Module Layer │
│ (APIService, Dependencies) │
├───────────────────────────────────────────┤
│ Foundation Layer │
│ (Network, Extensions, DIContainer, UI) │
└───────────────────────────────────────────┘
| Layer | Role | Examples |
|---|---|---|
| Foundation | General-purpose libraries not tied to the app | BinaryLoaderNetwork, BinaryLoaderExtensions |
| Module | App-specific services and dependency interfaces | APIService/Auth, Dependencies/Login |
| Scene | Self-contained feature modules per screen | LoginScene |
| App | Entry point responsible for assembling modules and navigation | Synstagram |
Lower layers are unaware of upper layers. Foundation doesn’t know about Module, and Module doesn’t know about Scene. Dependencies always flow downward.
2. Repository Structure
A modularized project requires two types of repositories.
| Repository Type | Role | Examples |
|---|---|---|
| Source repo | Contains the actual code | binaryloader-network, synstagram-scene-login |
| Spec repo | Manages podspec files by version | cocoapods-specs, synstagram-scene-cocoapods-specs |
Spec repos are separated by layer.
| Spec Repo | Target | Registered Pods |
|---|---|---|
cocoapods-specs |
Foundation layer | BinaryLoaderNetwork, BinaryLoaderExtensions, BinaryLoaderDIContainer, BinaryLoaderUI |
synstagram-module-cocoapods-specs |
Module layer | APIService, Dependencies |
synstagram-scene-cocoapods-specs |
Scene layer | LoginScene |
Separating spec repos by layer enables fine-grained access control within a team and allows the Foundation spec repo to be reused across different app projects.
3. Foundation Layer
General-purpose libraries not tied to any specific app. They can be used as-is in other projects.
3.1. BinaryLoaderNetwork
A network abstraction layer wrapping Moya.
# BinaryLoaderNetwork.podspec
Pod::Spec.new do |s|
s.name = 'BinaryLoaderNetwork'
s.version = '1.0.5'
s.ios.deployment_target = '13.0'
s.source_files = 'BinaryLoaderNetwork/Module/Source/*.swift'
s.dependency 'Moya', '15.0.0'
end
The core components are the NetworkTarget protocol and the NetworkProvider class.
// NetworkTarget.swift
public protocol NetworkTarget: TargetType {
var baseURL: URL { get }
var path: String { get }
var method: Method { get }
var sampleData: Data { get }
var task: Task { get }
var headers: [String: String]? { get }
}
// NetworkProvider.swift
public final class NetworkProvider<Target: NetworkTarget> {
private let provider: MoyaProvider<Target>
@discardableResult
public func request(target: Target, completion: @escaping Completion) -> Cancellable {
let cancellable = provider.request(target, completion: completion)
return cancellable
}
public func request(target: Target) async -> Result<Response, MoyaError> {
let result = await provider.request(target)
return result
}
}
Moya types are not directly exposed; they are provided through typealiases.
// Moya+Wrapping.swift
public typealias Method = Moya.Method
public typealias Task = Moya.Task
public typealias Response = Moya.Response
public typealias MoyaError = Moya.MoyaError
This way, if Moya changes its type names across versions, only the wrapping file needs to be updated, minimizing the blast radius of changes.
3.2. BinaryLoaderDIContainer
A Property Wrapper-based dependency injection container. Dependencies are declared with @Injectable and registered with Container.shared.register(type:).
# BinaryLoaderDIContainer.podspec
Pod::Spec.new do |s|
s.name = 'BinaryLoaderDIContainer'
s.version = '1.0.5'
s.source_files = 'BinaryLoaderDIContainer/Module/Source/*.swift'
end
3.3. BinaryLoaderUI
UI components are split into subspecs so only the needed parts are included.
# BinaryLoaderUI.podspec
Pod::Spec.new do |s|
s.name = 'BinaryLoaderUI'
s.version = '1.0.3'
s.default_subspec = :none
s.subspec 'InsetTextField' do |ss|
ss.source_files = 'BinaryLoaderUI/Module/InsetTextField/Source/*.swift'
end
end
Setting default_subspec = :none means pod 'BinaryLoaderUI' alone includes nothing. You must explicitly specify a subspec like pod 'BinaryLoaderUI/InsetTextField'.
4. Module Layer
The layer for app-specific business logic. It depends on the Foundation layer and is consumed by the Scene layer.
4.1. APIService
Encapsulates network API calls. Features are split into subspecs.
# APIService.podspec
Pod::Spec.new do |s|
s.name = 'APIService'
s.version = '1.0.8'
s.dependency 'BinaryLoaderExtensions', '1.0.3'
s.default_subspec = :none
s.subspec 'Auth' do |ss|
ss.source_files = 'APIService/Module/Auth/Source/*.swift'
ss.dependency 'BinaryLoaderNetwork', '1.0.5'
end
end
The Auth subspec exposes login/logout APIs through the AuthService class. It defines endpoints via AuthNetworkTarget (which implements NetworkTarget) and sends requests through NetworkProvider.
// AuthNetworkTarget.swift
struct AuthNetworkTarget {
enum Route {
case isAlreadyLogin
case login(request: AuthLoginModel.Request)
case logout
}
private let route: Route
}
extension AuthNetworkTarget: NetworkTarget {
var baseURL: URL {
let URLString = "https://photo.domain.com/photo/webapi"
return URLString.toAPIURL
}
var task: BinaryLoaderNetwork.Task {
var parameters: [String: Any] = [
"api": "SYNO.PhotoStation.Auth",
"version": "1"
]
switch route {
case .isAlreadyLogin:
parameters["method"] = "checkauth"
case .login(let request):
parameters["method"] = "login"
parameters["username"] = request.username
parameters["password"] = request.password
case .logout:
parameters["method"] = "logout"
}
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
}
}
4.2. Dependencies
Defines inter-scene dependency interfaces as protocols. Scene modules depend only on these protocols and don’t need to know the concrete implementations.
# Dependencies.podspec
Pod::Spec.new do |s|
s.name = 'Dependencies'
s.version = '1.0.2'
s.default_subspec = :none
s.subspec 'Login' do |ss|
ss.source_files = 'Dependencies/Module/Login/Source/*.swift'
end
s.subspec 'AlbumList' do |ss|
ss.source_files = 'Dependencies/Module/AlbumList/Source/*.swift'
end
end
// LoginDependency.swift
public protocol LoginDependency {
var viewController: UIViewController { get }
}
// AlbumListDependency.swift
public protocol AlbumListDependency {
func getViewController(username: String) -> UIViewController
}
The key here is that a Scene never directly imports another Scene. When LoginScene needs to navigate to AlbumListScene, it obtains the ViewController through the AlbumListDependency protocol. The actual implementation is injected by the App layer via the DI Container.
5. Scene Layer
Self-contained feature modules per screen. Uses the Clean Swift (VIP) architecture.
# LoginScene.podspec
Pod::Spec.new do |s|
s.name = 'LoginScene'
s.version = '1.0.12'
s.source_files = 'LoginScene/Module/Source/**/*.{swift,xib}'
s.resource = 'LoginScene/Module/Resources/*.xcassets'
s.dependency 'BinaryLoaderDIContainer', '1.0.5'
s.dependency 'BinaryLoaderExtensions', '1.0.3'
s.dependency 'BinaryLoaderUI/InsetTextField', '1.0.3'
s.dependency 'APIService/Auth', '1.0.8'
s.dependency 'Dependencies/Login', '1.0.2'
s.dependency 'Dependencies/AlbumList', '1.0.2'
end
The directory structure follows the Clean Swift pattern.
LoginScene/Module/
├── Source/
│ ├── Dependency/
│ │ └── LoginDependencyItem.swift
│ ├── Interactor/
│ │ ├── LoginInteractor.swift
│ │ └── LoginWorker.swift
│ ├── Model/
│ │ ├── LoginModel.swift
│ │ ├── LoginModelDTOMapper.swift
│ │ └── LoginModelErrorMapper.swift
│ ├── Presenter/
│ │ └── LoginPresenter.swift
│ ├── Router/
│ │ └── LoginRouter.swift
│ └── View/
│ ├── LoginViewController.swift
│ ├── LoginViewController.xib
│ ├── ContentView.swift
│ └── ...
└── Resources/
├── Color.xcassets
└── Image.xcassets
A Scene module implements the Dependencies protocol to expose its entry point.
// LoginDependencyItem.swift
public struct LoginDependencyItem: Dependency, LoginDependency {
public var viewController: UIViewController {
return LoginViewController(nibName: LoginViewController.className, bundle: LoginViewController.bundle)
}
}
Inter-scene navigation is done through Dependencies protocols in the Router.
// LoginRouter.swift
final class LoginRouter: NSObject, LoginRoutingLogic {
@Injectable
private var albumListDependency: AlbumListDependency?
func routeToAlbumList(username: String) {
let albumListVC = albumListDependency?.getViewController(username: username)
let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }
keyWindow?.rootViewController = albumListVC
}
}
@Injectable automatically injects the implementation registered in the DI Container.
6. App Integration
The App layer serves as the entry point that assembles all modules.
6.1. Podfile
source 'https://github.com/CocoaPods/Specs.git'
source 'https://github.com/binaryloader/cocoapods-specs.git'
source 'https://github.com/binaryloader/synstagram-module-cocoapods-specs.git'
source 'https://github.com/binaryloader/synstagram-scene-cocoapods-specs.git'
platform :ios, '13.0'
project 'Synstagram'
inhibit_all_warnings!
use_frameworks!
def scenes
pod 'LoginScene', '1.0.13'
end
target :'Synstagram' do
scenes
end
By specifying only the Scene Pod, CocoaPods resolves the dependency graph and automatically installs all pods from lower layers.
6.2. DI Container Registration
The App layer registers each Scene’s DependencyItem into the DI Container.
// DependencyContainer.swift
final class DependencyContainer {
static func registerAll() {
registerLoginSceneDependency()
}
}
extension DependencyContainer {
private static func registerLoginSceneDependency() {
Container.shared.register(type: LoginDependencyItem.self)
}
}
6.3. App Launch
// AppDelegate.swift
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
DependencyContainer.registerAll()
HierarchyCoordinator.configure(window: &window)
return true
}
}
// HierarchyCoordinator.swift
final class HierarchyCoordinator {
static func configure(window: inout UIWindow?) {
window = UIWindow(frame: UIScreen.main.bounds)
let launcher = LoginSceneLauncher()
launcher.launch(to: &window)
}
}
7. Version Management Workflow
When code in a module is modified, changes propagate in the following order.
Modify source code → Bump version → Create tag → Register podspec in spec repo → Update downstream dependencies → Repeat
For example, if BinaryLoaderNetwork is modified:
- Bump
s.versioninBinaryLoaderNetwork.podspec - Create and push
git tag 1.0.5 - Register
BinaryLoaderNetwork/1.0.5/BinaryLoaderNetwork.podspecin thecocoapods-specsrepo - Update the dependency version in APIService, which depends on BinaryLoaderNetwork
- Repeat the same process for APIService
- Finally, run
pod installin the App’s Podfile
This process may feel tedious, but since each module’s version is explicitly pinned, you can track exactly which version of the code is in use at any point in time.
Leave a comment