Architecture With Nico

Software Architecture and Design for iOS.

Published on 05 February 2024

Exploring the Crucial Role of the Composition Root

In the realm of Swift iOS development, the Composition Root emerges as a fundamental concept, serving as the cornerstone for building modular, maintainable, and testable applications. This blog post aims to unravel the significance of the Composition Root, its key principles, and how it contributes to crafting robust software architectures.

Understanding the Composition Root

The Composition Root is the central place in your application where the dependency graph is constructed. It's the point where various components, services, and dependencies are brought together and injected into the application's core. By encapsulating the configuration logic in the Composition Root, we achieve a high degree of flexibility, making the system easily adaptable to changes.

The Composition Root is a Layer, not a single Class or a single Method. It's simply a term that refers to the place where the application's dependencies are composed and wired together. This could be Factories, Builders, etc.

// Example: Composition Root in Swift

class SceneDelegate {
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let window = UIWindow(windowScene: scene as! UIWindowScene)
        window.rootViewController = CompositionRoot().createRootViewController()
        window.makeKeyAndVisible()
    }
}

class CompositionRoot {    
    let navigationController = UINavigationController()

    func createRootViewController() -> UIViewController {
        let dataFetcher = RemoteHomeFetcher()
        let apiService = ApiService(dataFetcher: dataFetcher)
        let homeViewModel = HomeViewModel(apiService: apiService)
        let homeViewController = HomeViewController(viewModel: homeViewModel)
        
        let productFlow = createProductFlow()
        homeViewController.onProductSelected = productFlow.start

        navigationController.viewControllers = [homeViewController]
        return navigationController
    }

    private func createProductFlow() -> ProductFlow {
        return ProductFlow(
            navigationController: navigationController,
            createProductViewController: { 
                let dataFetcher = RemoteProductFetcher()
                let apiService = ProductApiService(dataFetcher: dataFetcher)
                let productViewModel = ProductViewModel(apiService: apiService)
                return ProductViewController(viewModel: productViewModel)
            }
        )
    }

    class ProductFlow {
        let navigationController = UINavigationController()
        let createProductViewController: () -> UIViewController

        func start() {
            navigationController.pushViewController(
                createProductViewController(),
                animated: true
            )
        }
    }
}

Advantages of a Well-Defined Composition Root

Testability

The Composition Root greatly facilitates unit testing by allowing easy substitution of dependencies with mocks or stubs. This ensures that each component can be tested in isolation, promoting a robust testing suite.

Maintainability

Centralizing the configuration logic in the Composition Root enhances the maintainability of the codebase. When changes are required, developers can focus on this specific area rather than scattering modifications throughout the entire application.

Flexibility and Adaptability

As the application evolves, the Composition Root's encapsulation of dependencies provides a flexible architecture. Introducing new features or modifying existing ones becomes a streamlined process, as the changes are confined to the Composition Root.

Best Practices

Keep it Simple

The Composition Root should be concise and focused on configuring dependencies. Avoid unnecessary complexity and keep the logic straightforward.

Use Dependency Injection

Embrace the principle of dependency injection to ensure that components receive their dependencies from external sources, making the system more modular and decoupled.

In conclusion, understanding and implementing a well-structured Composition Root is paramount for building scalable and maintainable Swift iOS applications. By adhering to the principles outlined here, developers can harness the power of a solid Composition Root to navigate the intricacies of dependency management successfully.

Adapting to Real-world Scenarios

Dynamic Dependency Configuration

In scenarios where dependencies need dynamic configuration, the Composition Root adapts gracefully. Consider a situation where the base URL of an API is dynamic and determined at runtime, based on an A/B test. The Composition Root can handle such dynamic configurations seamlessly.

class ApiServiceFactory {
    let configurationService: ConfigurationService

    func makeApiService() -> ApiService {
        let dataFetcher = RemoteDataFetcher()
        let newDataFetcher = NewRemoteDataFetcher()
        let apiService = ApiService(
            dataFetcher: configurationService.isNewApiEnabled ? newDataFetcher : dataFetcher
        )
        return apiService
    }
}

// Example usage:
let productsApiService = ApiServiceFactory(configurationService: configurationService).makeApiService()
let productsViewModel = ProductsViewModel(apiService: productsApiService)

Integrating Third-party Libraries

When integrating third-party libraries into an iOS project, the Composition Root acts as the integration point. This ensures that the third-party dependencies are properly configured and seamlessly integrated into the application. If you add dependency injection to the mix, you can easily swap out third-party libraries with minimal changes to the rest of the application.

class ProductsStorageFactory {
    
    func makeProductsStorage() -> ProductsStorage {
        return UserDefaultsProductsStorage(userDefaults: .standard)
        // or
        return CoreDataProductsStorage(coreDataStack: coreDataStack)
        // or
        return RealmProductsStorage(realm: realm)
    }
}

// Example usage:
let productsStorage = ProductsStorageFactory().makeProductsStorage()
let saveProductUseCase = SaveProductUseCase(productsStorage: productsStorage)

Scaffolding the Composition Root and your Application

Remember, the Composition Root is not a single class or method; it's a layer in your application. It's a place where you wire up your dependencies, and it can be composed of multiple classes and methods. Here's a high-level overview of how you can structure your Composition Root:

├── Application
│   ├── SceneDelegate.swift
├── CompositionRoot
│   ├── RootViewControllerFactory.swift
│   ├── RemoteHomeFetcher.swift (implementation)
│   ├── RemoteProductsFetcher.swift (implementation)
│   ├── ProductsStorageFactory.swift
|   ├── RealmProductsStorage.swift (implementation)
├── Features
│   ├── Home
│   │   ├── HomeViewController.swift
│   │   ├── HomeViewModel.swift
│   │   ├── HomeDataFetcher.swift (possibly a protocol)
│   ├── Products
│   │   ├── ProductViewController.swift
│   │   ├── ProductViewModel.swift
│   │   ├── ProductDataFetcher.swift (possibly a protocol)
│   │   ├── ProductStorage.swift (possibly a protocol)

Conclusion

In the dynamic landscape of Swift iOS development, the Composition Root stands as a pivotal concept, guiding developers in crafting modular, maintainable, and adaptable applications. By embracing the principles outlined in this exploration, developers can leverage the power of a well-defined Composition Root to navigate the complexities of dependency management with finesse. From dynamic configurations to third-party integrations, the Composition Root remains a stalwart ally in the pursuit of code excellence.