Dependency Injection & Singleton
Understanding Dependency Injection in Swift
Dependency Injection is a powerful design pattern in software development that promotes code flexibility, testability, and maintainability. In Swift, it plays a crucial role in creating loosely coupled and easily testable components.
What is Dependency Injection?
At its core, Dependency Injection is a technique where the dependencies of a class are provided from the outside, rather than being created within the class itself. Instead of a class creating its dependencies, they are injected into the class during initialization.
- Decoupling: Dependency Injection helps decouple components by ensuring that a class doesn't rely on the concrete implementations of its dependencies. This makes the code more modular and easier to understand.
- Testability: By injecting dependencies, it becomes simpler to replace real implementations with mock objects during testing. This promotes unit testing and ensures that tests focus on the behavior of the unit, not its dependencies.
- Flexibility: Injecting dependencies makes it easier to switch between different implementations of the same dependency. This flexibility is especially valuable when requirements change or when different implementations are needed for various scenarios.
To better understand dependency injection, we will first make a design that uses singletons, then we will use dependency injection. Let’s look at this through an example project.
Singleton Pattern:
The Singleton pattern involves creating a single, shared instance of a class that provides global access to that instance. While Singleton can simplify the management of certain resources and ensure a single point of control, it comes with its set of drawbacks. Global state and tight coupling can make the code less modular and harder to test. Additionally, Singletons can introduce hidden dependencies, making it challenging to understand and reason about the flow of data.
struct PostModel : Codable , Identifiable{
let userId : Int
let id : Int
let title : String
let body : String
}
class ProductionDataService {
static let instance = ProductionDataService() //--Singleton
let url : URL = URL(string : "https://jsonplaceholder.typicode.com/posts")!
func getData() ->AnyPublisher<[PostModel], Error >{
URLSession.shared.dataTaskPublisher(for: url)
.map({$0.data})
.decode(type: [PostModel].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
class DependencyInjectionViewModel : ObservableObject {
@Published var dataArray : [PostModel] = []
var cancellables = Set<AnyCancellable>()
init(){
loadPosts()
}
func loadPosts(){
ProductionDataService.instance.getData()
.sink { _ in
} receiveValue: { [weak self] returnedPost in
self?.dataArray = returnedPost
}
.store(in: &cancellables)
}
}
struct DependencyInjectionView: View {
@StateObject var vm = DependencyInjectionViewModel()
var body: some View {
VStack {
ForEach(vm.dataArray){post in
Text(post.title)
.padding()
}
}
}
}
- This class is a singleton (
static let instance = ProductionDataService()
). - It contains a method
getData()
that returns a CombineAnyPublisher
of an array ofPostModel
or an error. - It uses
URLSession.shared.dataTaskPublisher
to make a network request. - The received data is then mapped to the actual data using
.map({$0.data})
. - The data is decoded into an array of
PostModel
using.decode(type:decoder:)
. - The processing is moved to the main thread using
.receive(on: DispatchQueue.main)
. - Finally, the publisher is erased to
AnyPublisher
using.eraseToAnyPublisher()
. - The code follows the MVVM (Model-View-ViewModel) architecture.
ProductionDataService
is responsible for fetching data from a URL.DependencyInjectionViewModel
is a view model that uses Combine to handle data and update the view.
First problem is that Singletons are global. This means we can access the Singleton instance from anywhere in our application — whether it’s within a specific class, a view, or virtually any other part of the app. While having global access is not inherently problematic, it can become confusing as your app grows in size, especially when dealing with numerous global variables. Furthermore, if the Singleton instance is accessed from various places in the app simultaneously, it introduces potential conflicts. In summary, when developing applications, it’s advisable to minimize the use of global variables, as larger apps with multiple global variables can lead to increased complexity. Many well-established production apps often refrain from extensive use of global variables to maintain code clarity and avoid potential issues.
Second problem; we can’t customize init. For example
class ProductionDataService {
static let instance = ProductionDataService(title: "text") //--Singleton
init(title : String){
}
let url : URL = URL(string : "https://jsonplaceholder.typicode.com/posts")!
func getData() ->AnyPublisher<[PostModel], Error >{
URLSession.shared.dataTaskPublisher(for: url)
.map({$0.data})
.decode(type: [PostModel].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
Third problem; can’t swap out dependencies
Essentially, instead of initializing our dependencies, which, in this case, is our data service, within the data service itself, we prefer to initialize it early in our app, almost at the beginning. Then, we inject it into the rest of our app — into all the views and view models that need a reference to the data service. Let’s explore how we can achieve this injection in our view and view model.
In our view model, we obviously need a reference to a data service so that we can call getData
. Here, I'll create a reference, let's call it dataService
, of type DataService
. In this view model, we can now call dataService.getData()
instead of accessing the Singleton directly.
Now, when initializing our view model, we need to pass in a data service. This is dependency injection in a nutshell — it’s literally injecting your dependencies into your class or structure. Here, we are taking the data service, which is our dependency, and injecting it into this view model through the initializer. This way, the view model has access to the data service.
class ProductionDataService {
let url : URL = URL(string : "https://jsonplaceholder.typicode.com/posts")!
func getData() ->AnyPublisher<[PostModel], Error >{
URLSession.shared.dataTaskPublisher(for: url)
.map({$0.data})
.decode(type: [PostModel].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
class DependencyInjectionViewModel : ObservableObject {
@Published var dataArray : [PostModel] = []
var cancellables = Set<AnyCancellable>()
let dataService : ProductionDataService
init(dataService : ProductionDataService){
self.dataService = dataService
loadPosts()
}
func loadPosts(){
dataService.getData()
.sink { _ in
} receiveValue: { [weak self] returnedPost in
self?.dataArray = returnedPost
}
.store(in: &cancellables)
}
}
struct DependencyInjectionView: View {
@StateObject var vm : DependencyInjectionViewModel
init(dataService : ProductionDataService){
_vm = StateObject(wrappedValue: DependencyInjectionViewModel(dataService: dataService))
}
var body: some View {
VStack {
ForEach(vm.dataArray){post in
Text(post.title)
.padding()
}
}
}
}
- Role of DependencyInjectionViewModel: This is a class responsible for managing data operations in our SwiftUI application. It utilizes Dependency Injection by receiving an instance of
ProductionDataService
through its initializer. - Role of DependencyInjectionView: This is a SwiftUI view that displays data. It initializes a
DependencyInjectionViewModel
with an instance ofProductionDataService
, showcasing the use of Dependency Injection.
- Benefits of Dependency Injection in this Context:
- Flexibility: Dependency Injection allows us to easily switch or substitute the
ProductionDataService
with another data service implementation. This flexibility is crucial when the application evolves or when different data sources need to be tested. - Testability: By injecting dependencies, we can provide mock or test instances of services during unit testing. This helps in isolating and testing components in a controlled environment without relying on concrete implementations.
- DependencyInjectionViewModel’s Usage of Dependency Injection:
- The
DependencyInjectionViewModel
class exemplifies Dependency Injection by taking aProductionDataService
instance as a parameter in its initializer. - This design choice allows the
DependencyInjectionViewModel
to remain agnostic about the concrete implementation of the data service. It only knows that it needs a service conforming to the specified protocol/interface.
Addressing the Limitation of Customizing the Initialization Process with Dependency Injection:
One significant advantage of using Dependency Injection (DI) is its capability to seamlessly overcome the limitation of customizing the initialization process, which can be challenging with conventional methods. In the context of Swift and object-oriented programming, especially when dealing with class instances, it’s often cumbersome to customize the initialization process due to the constraints of designated initializers.
However, by embracing Dependency Injection, this limitation is effectively mitigated. In Dependency Injection, rather than relying solely on the default initializer or dealing with the constraints of designated initializers, we can inject dependencies into a class through its initializer. This allows for a more flexible and customizable initialization process, as we have the freedom to provide specific instances or configurations during object creation.
In practical terms, this means that with Dependency Injection, we gain the ability to tailor the initialization of our classes according to specific needs, facilitating a more modular and adaptable codebase. This flexibility becomes particularly valuable when dealing with complex systems or when integrating different components that may require distinct setups during initialization.
class ProductionDataService {
let url : URL
init(url: URL) {
self.url = url
}
func getData() ->AnyPublisher<[PostModel], Error >{
URLSession.shared.dataTaskPublisher(for: url)
.map({$0.data})
.decode(type: [PostModel].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
- Limitation of Access to DataService:
- Challenge: Initially, the classes in the application could only access the DataService if it was injected into them. This constraint ensures that the DataService is intentionally provided to the classes that need it, promoting better control over dependencies.
- Solution: The use of Dependency Injection addresses this issue. Now, DataService is injected into the classes where it’s needed, providing a clear and intentional way of managing dependencies.
2. Customization of the Network Request:
- Challenge: The inability to customize the network request was a limitation in the initial setup.
- Solution: With Dependency Injection, this limitation is overcome. Customization of the network request is achieved by injecting the DataService into the classes, allowing for flexibility and adaptability in the initialization process.
3. Swapping Out Services and Protocol Usage:
- Challenge: Another challenge highlighted is the difficulty in swapping out services or dependencies. In a real-world scenario, different variations of DataService might be required for testing, quality assurance, beta testing, etc.
- Solution: The statement introduces the concept of a “mock” version of DataService created through a protocol named
DataService
. Protocols define a blueprint of methods and properties that conforming classes must implement. By using a protocol, different variations of DataService (e.g., a mock version for testing) can be easily created. This allows for the swapping of services by adhering to the same protocol, ensuring that the classes consuming the DataService are not tightly coupled to a specific implementation.
Significance of Protocols in Dependency Injection:
- Flexibility: Protocols enable the creation of interchangeable components. In the provided example, different implementations of
DataService
(like a mock version) can conform to the same protocol, providing flexibility in choosing and swapping out services based on the application's requirements. - Testability: Protocols facilitate the creation of mock objects for testing. Mock objects emulate the behavior of real objects, enabling thorough testing of various scenarios without relying on actual network requests or data services. This is crucial for testing the functionality of classes that depend on external services.
In summary, Dependency Injection, coupled with protocols, not only addresses challenges in dependency management but also enhances the testability and flexibility of an application by allowing the creation of mock objects and facilitating the swapping of services through a standardized interface.
protocol DataServiceProtocol {
func getData() ->AnyPublisher<[PostModel], Error >
}
class MockDataService : DataServiceProtocol {
let url : URL
init(url: URL) {
self.url = url
}
func getData() -> AnyPublisher<[PostModel], Error> {
URLSession.shared.dataTaskPublisher(for: url)
.map({$0.data})
.decode(type: [PostModel].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
class DependencyInjectionViewModel : ObservableObject {
@Published var dataArray : [PostModel] = []
var cancellables = Set<AnyCancellable>()
let dataService : MockDataService
init(dataService : MockDataService){
self.dataService = dataService
loadPosts()
}
func loadPosts(){
dataService.getData()
.sink { _ in
} receiveValue: { [weak self] returnedPost in
self?.dataArray = returnedPost
}
.store(in: &cancellables)
}
}
struct DependencyInjectionView: View {
@StateObject var vm : DependencyInjectionViewModel
init(dataService : MockDataService){
_vm = StateObject(wrappedValue: DependencyInjectionViewModel(dataService: dataService))
}
var body: some View {
VStack {
ForEach(vm.dataArray){post in
Text(post.title)
.padding()
}
}
}
}
- DataServiceProtocol:
DataServiceProtocol
is a protocol that defines the contract for a service providing data.- It declares a method
getData()
that returns a CombineAnyPublisher<[PostModel], Error>
.
2. MockDataService:
MockDataService
conforms toDataServiceProtocol
.
In conclusion, Dependency Injection (DI) stands as a powerful design principle that not only enhances the flexibility and maintainability of your code but also plays a pivotal role in achieving a modular and testable architecture.
By decoupling components and allowing dependencies to be injected from external sources, DI enables a more dynamic and adaptable system. This proves particularly crucial when it comes to scaling your application or accommodating changes over time.
As we’ve explored in this piece, DI promotes a cleaner separation of concerns, making your codebase more resilient to modifications and additions. The ability to easily switch out dependencies, such as different data services, is a testament to DI’s effectiveness.
Moreover, the benefits of DI extend beyond just code organization. The increased testability achieved through dependency injection empowers developers to write more robust and reliable test suites. This, in turn, contributes to the overall quality and stability of your software.
In your journey as a software developer, embracing Dependency Injection is not merely adopting a technique; it’s cultivating a mindset that prioritizes modularity, scalability, and testability. So, as you integrate DI into your projects, remember that you’re not just writing code; you’re architecting systems that can evolve and endure.(My reference source is Swiftul Thinking youtube channel)
Happy coding!