This article is the final chord in our architecture trilogy. We've already learned to bring order within screens using MVVM and manage transition flows through Coordinator. But one awkward question remains: who will create all these dependencies? If your Coordinator has turned into a dumping ground for a dozen services that it simply passes along, it's time to introduce Factory. You'll learn how to separate object creation from object management, why global DI containers are slow-acting poison, and how to build a system where each component receives only what it needs without knowing the unnecessary details.
When we first start implementing Coordinator, everything seems beautiful. But a couple of months pass, the project grows, and suddenly it turns out that your MainCoordinator accepts NetworkService, DatabaseService, AnalyticsService, ImageLoader, and half a dozen other "absolutely necessary" things in its initializer. Moreover, the Coordinator itself doesn't use these services—it just holds onto them to pass them to the ViewModel later.
Congratulations, you've built a "dependency pipeline." This is a bad practice. The Coordinator should know when to show a screen, but it doesn't need to know how to assemble that screen brick by brick. That's what the Factory pattern is for.
The Problem: The Overloaded Coordinator
Let's face the truth: if your code looks like this, you have decomposition problems:
// DON'T DO THIS
final class ProductCoordinator: Coordinator {
let networkService: NetworkProtocol
let authService: AuthProtocol
let cache: CacheProtocol
// ... 5 more services
func start() {
// Coordinator knows too much about assembly details
let vm = ProductViewModel(network: networkService, auth: authService)
let vc = ProductViewController(viewModel: vm)
navigationController.pushViewController(vc, animated: true)
}
}
Here, the Coordinator is tightly coupled to concrete implementations of ViewModel and ViewController. If you want to change how ProductViewModel is created (for example, add a new logger), you'll have to dig into the Coordinator. Now imagine you have ten screens like this.
The Solution: Dependency Container & Factory
The idea is simple: we create a separate object (or group of objects) whose sole task is knowing how to assemble a screen. I prefer splitting this into two parts: Dependency Container (service storage) and Module Factory (screen creator).
1. Dependency Container: The Source of Truth
This is a long-lived object that stores singletons or service configurations. But importantly: it shouldn't be a global singleton like Container.shared. That's a trap that turns your code into an untestable mess (Service Locator anti-pattern).
The container should be passed explicitly or injected into the Factory.
protocol DependencyContainerProtocol {
var networkService: NetworkProtocol { get }
var databaseService: DatabaseProtocol { get }
}
final class AppDependencyContainer: DependencyContainerProtocol {
// Lazy initialization to avoid creating everything at once
lazy var networkService: NetworkProtocol = NetworkService()
lazy var databaseService: DatabaseProtocol = DatabaseService()
}
2. Module Factory: The Builder
The Factory is a "workshop" that churns out ready-made modules. It knows about the Container's existence and can extract the necessary parts from it.
protocol ModuleFactoryProtocol {
func makeProductListModule(coordinator: ProductListCoordinator) -> UIViewController
func makeProductDetailModule(product: Product) -> UIViewController
}
final class ModuleFactory: ModuleFactoryProtocol {
private let container: DependencyContainerProtocol
init(container: DependencyContainerProtocol) {
self.container = container
}
func makeProductListModule(coordinator: ProductListCoordinator) -> UIViewController {
let viewModel = ProductListViewModel(
network: container.networkService,
coordinator: coordinator
)
return ProductListViewController(viewModel: viewModel)
}
}
Integrating Factory into Coordinator
Now our Coordinator becomes truly lightweight. It simply asks the factory: "Give me a controller for the product list, here's a reference to me for feedback."
final class ProductListCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var navigationController: UINavigationController
private let factory: ModuleFactoryProtocol
init(navigationController: UINavigationController, factory: ModuleFactoryProtocol) {
self.navigationController = navigationController
self.factory = factory
}
func start() {
let viewController = factory.makeProductListModule(coordinator: self)
navigationController.pushViewController(viewController, animated: true)
}
}
Look at that cleanliness! The Coordinator now doesn't know about NetworkService, it doesn't know about ViewModel. If tomorrow you decide to replace MVVM with VIPER for one specific screen—you simply change the implementation in the Factory. The Coordinator won't even know about it.
Why not Service Locator?
There's often a temptation to do ServiceLocator.shared.get(NetworkProtocol.self). It seems convenient: no need to pass anything through initializers.
But I'm categorically against this in large projects.
-
Hidden dependencies: Looking at a controller's
init, you don't see that it needs networking. This only surfaces at runtime if you forget to register the service. - Testing complexity: You'll have to reset the global singleton's state before each test, which turns parallel test execution into hell.
Explicit passing through the Factory (Constructor Injection) is the honest way. Yes, there's slightly more code, but you sleep peacefully at night.
Composition Root: Where it all begins
Where is this whole machinery created? In the Composition Root. Usually, this is SceneDelegate or AppDelegate. This is the only place in the app that knows about all components and ties them together.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var appCoordinator: AppCoordinator?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let container = AppDependencyContainer()
let factory = ModuleFactory(container: container)
let nav = UINavigationController()
appCoordinator = AppCoordinator(navigationController: nav, factory: factory)
appCoordinator?.start()
let window = UIWindow(windowScene: windowScene)
window.rootViewController = nav
self.window = window
window.makeKeyAndVisible()
}
}
Practical Tips & Observations
-
Protocols for Factories: Always hide the factory behind a protocol. This lets you write a
MockFactoryfor tests and verify that the coordinator is actually trying to create the right screen. -
Many factories are better than one: If the project is huge, don't make one
GiantFactory. Split them by features:AuthFactory,ProfileFactory,ChatFactory. -
SwiftUI and DI: In SwiftUI there's
EnvironmentObject, which is often confused with DI. Remember thatEnvironmentObjectis essentially a global variable within the view tree. For complex business logic, I still recommend using classic DI through initializers of yourObservableObject(ViewModel).
The combination of MVVM, Coordinator, and Factory is the gold standard for scalable iOS applications. This gives you:
- Testability: Each component is isolated.
- Flexibility: You can change UI or logic without rewriting navigation.
- Order: Every object has one clear responsibility.
Yes, at first it seems like you're writing a lot of "extra" code. But as soon as you get a task like "let's replace the second screen in this flow with a new one, but only for users from the US," you'll thank yourself for choosing this path.
Top comments (0)