iOS Development: The Power of a Dependency Injection Container
Originally published on Medium.
Over the recent quarter, I embarked on an exhilarating journey with a new client, diving deep into the heart of their iOS trading platform. Built on the sturdy foundations of UIKit, this app had been the brainchild of a remarkably talented developer with a rich background in web development. This developer brought a unique blend of web-centric practices to the iOS realm, a testament to the versatility and adaptability of modern coders.
However, the transition from web to iOS development is not without its challenges. The paradigms and patterns that excel in one environment don’t always translate seamlessly to another. This is particularly true in the realm of iOS development, where the elegance and efficiency of code architecture are paramount. My journey through the app’s codebase was as much about discovery as it was about refinement, learning not only from the successes but also from the missteps along the way.
One of the early revelations was the glaring absence of a dependency injection container. In an application of this scale, managing network services and ensuring their optimal operation without redundant instances was crucial. The need for a streamlined, efficient, and testable solution was evident.
Embarking on the Quest for Clean Architecture
Armed with insights from the trenches of the app’s code and the wisdom gleaned from foundational resources on SOLID principles, I set out to forge a new tool in our development arsenal. The goal was clear: to create a dependency injection container that could serve as the backbone for managing the app’s myriad services, from networking to AWS integrations and beyond.
This endeavor was not without its hurdles. Dependencies have their own dependencies, a complex web that requires careful navigation. The container had to be more than just a repository; it needed to be the orchestrator of services, capable of loading, unloading, and swapping components on the fly, all while maintaining the sanctity of our ‘single source of truth’ for network operations.
The Odyssey of Discovery: Unearthing the Pillars of Dependency Management
The journey to architectural mastery is seldom a solitary endeavor; it is paved with the insights and breakthroughs of those who have navigated its complexities before us. In my quest to transcend the conventional boundaries of dependency management within our iOS app, I delved deep into the annals of software engineering wisdom. The objective was clear: to distill the essence of dependency management and imbue our project with the solid foundation it deserved.
The exploration led me to two pivotal resources, each a beacon of knowledge in the vast sea of software development literature. These guides were not merely informational; they were transformative, offering a lens through which the core tenets of SOLID principles were not just visible but palpable. The principles of Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion became the guiding stars of our architectural redesign.
- SOLID Design Principles Explained: Dependency Inversion Principle with Code Examples
- Understanding the dependency inversion principle in TypeScript - LogRocket Blog
Crafting a Future on the Bedrock of Knowledge
Armed with this newfound understanding, the creation of our dependency injection container took on a new dimension. It was no longer just a technical solution; it was a manifestation of the principles that now underpinned our development philosophy. The container was to be more than a mechanism for managing dependencies; it was to be a testament to the transformative power of SOLID principles in action.
The Craftsmanship of the Container
The result of this labor is a meticulously designed class that not only houses our dependencies but also empowers our development process with unmatched flexibility and control. Here’s a glimpse into the blueprint of our creation:
protocol InjectionContainerProtocol {
static var shared: InjectionContainerProtocol { get }
//Register and Unregister CustomAPI after setting up it's network. Only possible on authentication
func registerApollo() -> CustomAPIProtocol
func unRegisterApollo()
//Provide the registered service
func resolve<T>(_ type: T.Type) throws -> T
// func register<T>(_ type: T.Type, service: T)
// func register<T>(_ type: T.Type, factory: @escaping (InjectionContainer) throws -> T) throws
// func replace<T>(_ type: T.Type, with newService: T)
// func unregister<T>(_ type: T.Type)
}
enum InjectionContainerError: Error {
case serviceNotRegistered
case typeCastingFailed
}
/// This class is responsible for registering, resolving, and managing the lifecycle of various services throughout the application.
///
/// Registering a new service
/// ```
/// InjectionContainer.shared.register(NetworkMonitorProtocol.self, service: NetworkMonitor())
/// ```
/// Resolving a service
/// ```
/// let networkService: NetworkMonitorProtocol? = InjectionContainer.shared.resolve(NetworkMonitorProtocol.self)
/// ```
/// Replacing a service
/// ```
/// InjectionContainer.shared.replace(NetworkMonitorProtocol.self, with: NetworkMonitor_v2())
/// ```
class InjectionContainer: InjectionContainerProtocol {
/// The shared instance of the InjectionContainer.
static let shared:InjectionContainerProtocol = InjectionContainer()
/// Dictionary to hold the services keyed by a String identifier.
private var serviceList: [String: Any] = [:]
/// Private initializer to enforce singleton pattern.
private init() {
self.registerServices()
}
/// Registers all the services used in the application.
/// This is where all the services are linked to their respective protocols.
private func registerServices() {
do {
//Utility Services
self.register(AppManagerProtocol.self, service: AppManager())
self.register(UserManagerProtocol.self, service: UserManager())
//REST Services
self.register(RestProtocol.self, service: RestService())
//Network Services
self.register(AWSManagerProtocol.self, service: AWSManger())
self.register(NetworkMonitorProtocol.self, service: NetworkMonitor.shared)
try self.register(ApolloProtocol.self) { container in
let appManager = try self.resolve(AppManagerProtocol.self)
return Apollo(keychain: appManager)
}
} catch {
Log.error("Error Registering Base Services:\(error)")
fatalError()
}
}
/// Registers the ApolloAPI which requires a preexisting ApolloClient to have been registered.
func registerApollo() -> CustomAPIProtocol{
do {
try self.register(CustomAPIProtocol.self) { container in
let apollo = try self.resolve(ApolloProtocol.self)
apollo.startServices()
return ApolloAPI(apollo: apollo)
}
return try self.resolve(CustomAPIProtocol.self)
} catch {
Log.error("Error Registering CustomAPIProtocol:\(error)")
fatalError()
}
}
func unRegisterApollo(){
do {
let apollo = try self.resolve(ApolloProtocol.self)
apollo.stopServices()
unregister(CustomAPIProtocol.self)
} catch {
Log.info("Error deRegistering CustomAPIProtocol:\(error)")
}
}
/// Resolves and returns the service for the specified type.
///
/// - Parameter type: The protocol/type of the service to resolve.
/// - Returns: An instance of the requested service if available; otherwise, `nil`.
func resolve<T>(_ type: T.Type) throws -> T {
let key = String(describing: type)
guard let service = serviceList[key] else {
throw InjectionContainerError.serviceNotRegistered
}
guard let castedService = service as? T else {
throw InjectionContainerError.typeCastingFailed
}
return castedService
}
/// Registers a service.
///
/// - Parameters:
/// - type: The protocol/type of the service.
/// - service: The concrete implementation of the service.
private func register<T>(_ type: T.Type, service: T) {
let key = String(describing: type)
serviceList[key] = service
}
/// Registers a service with a factory closure.
///
/// - Parameters:
/// - type: The protocol/type of the service.
/// - factory: A closure that takes a DIContainer and returns an instance of the service.
private func register<T>(_ type: T.Type, factory: @escaping (DIContainer) throws -> T) throws {
let key = String(describing: type)
do {
serviceList[key] = try factory(self)
}catch{
throw error
}
}
/// Replaces a registered service with a new service.
///
/// - Parameters:
/// - type: The protocol/type of the service to be replaced.
/// - newService: The new service instance to register.
private func replace<T>(_ type: T.Type, with newService: T) {
unregister(type)
register(type, service: newService)
}
/// Unregisters a service.
///
/// - Parameter type: The protocol/type of the service to unregister.
private func unregister<T>(_ type: T.Type) {
let key = String(describing: type)
serviceList.removeValue(forKey: key)
}
}
This container is more than just code; it’s a paradigm shift in how I now approach iOS development. It’s about embracing the robustness of Swift and UIKit while ensuring the architecture remains as elegant and efficient as the devices it powers.
Forward into the Future
As I continue to refine and expand my knowledge, the dependency injection container stands as a beacon of innovation and coding convention. It’s a testament to a collaborative spirit of development, where learning from the past and adapting to the future go hand in hand.
I welcome community feedback and insights as I journey forward, aiming to craft applications that not only meet the demands of today but also anticipate the possibilities of tomorrow.
- Disclaimer: The core ideas and the initial draft of this article were all me, but I had a bit of help from my AI sidekick to polish things up. It’s been brilliant at adding that touch of writing finesse that would’ve taken me ages to nail down on my own. So, hats off to AI for being the unsung hero in the editing room! I genuinely hope you find a nugget or two in here that resonates or proves handy. After all, sharing is caring, right
Till the Next Byte, The British Dev’s Diary.