Clarifying Dependency Injection: A Beginner’s Guide to Local Container Implementation

Pravin Tate
Level Up Coding
Published in
6 min readDec 12, 2023

--

Unlocking the Power of Dependency Injection for Swift Developers

https://stackify.com/dependency-injection/

What is Dependency Injection (DI)?

In software engineering, dependency injection is a programming technique in which an object or function receives other objects or functions that it requires, as opposed to creating them internally. Dependency injection aims to separate the concerns of constructing objects and using them, leading to loosely coupled programs. (link)

In Swift there are many ways to achieve the DI like init/constructor, delegate, closures etc. Mostly using pattern is init/constructor. Let’s consider we have a view in swiftUI and it depends on its ViewModel so mostly it is injected via the init method, which is good but the only drawback is that in case our view depends on multiple things then the init length will increase as well as whenever we create an object of the view we need to create all dependency and inject it.

So, in this article, we are checking another way of DI. We will create our container which holds all dependencies. Here we need to register all dependencies at the app launch and use them as per the need.

Inject object types

// Creating object type.
// New: Always return new instance
// Shared: Always return shared instance
public enum DependencyStoringType {
case new, shared
}

Let’s write a dependency container now.

protocol DependencyContainerProtocol {
func register<Dependency>(type: Dependency.Type, dependency: @escaping () -> Dependency)
func resolve<Dependency>(type: Dependency.Type, mode: DependencyStoringType) throws -> Dependency
func remove<Dependency>(type: Dependency.Type)
}

Our container adopts the above protocol which contains 3 methods. Which will be used to register, resolve and remove Dependency.

Dependency Container

final class DependencyContainer {
// Hold all dependency data in this dict.
private var container: [String: () -> Any] = [:]
// Hold all shared dependency data in this dict.
private var sharedContainer: [String: Any] = [:]
}
extension DependencyContainer: DependencyContainerProtocol {
}

Note: We can make this container as singleton but here we write resolver class which is singleton so we are not creating a container as shared here.

Register Dependency


func dependencyKey<Dependency>(type: Dependency.Type) -> String {
String(describing: type)
}

func register<Dependency>(type: Dependency.Type,
dependency: @escaping () -> Dependency) {
let key = dependencyKey(type: type)
container[key] = dependency
}

The above code is simple, where we have one method which returns the string of the passed Dependency which we are using for the store dependency in the dictionary and a second method used for the register dependency in the dictionary.

Note: Here we are not storing dependency objects in the dictionary, instead we are storing closure which will return the object when we call it.

Remove Dependency

func remove<Dependency>(type: Dependency.Type) {
let key = dependencyKey(type: type)
guard container.keys.contains(where: { $0 == key }) else {
return
}
container.removeValue(forKey: key)
sharedContainer.removeValue(forKey: key)
}

Check if is there a respective dependency present in both dictionaries and remove it from both dictionaries.

Resolve Dependency

func resolve<Dependency>(type: Dependency.Type, 
mode: DependencyStoringType = .new) -> Dependency {
let key = dependencyKey(type: type)
guard container.keys.contains(where: { $0 == key }) else {
fatalError("Dependency not register for a \(key)")
}

switch mode {
case .new:
// Always return the new object of the dependency
if let dependencyObject = container[key]?() as? Dependency {
return dependencyObject
}
case .shared:
// Always return the shared object of the dependecy.
// If it already present in shared dict. then return else
// get object from container dict, store it in shared dict
// and returns
if let object = sharedContainer[key] as? Dependency {
return object
}
if let dependencyObject = container[key]?() as? Dependency {
sharedContainer[key] = dependencyObject
return dependencyObject
}
}
fatalError("Dependency not register for a \(key)")
}

While resolving the dependency we are asking for the type (new or shared) so that we return the object from the container. (check code comments)

The Dependency container is ready, Let’s write the Resolver which uses this container and its single point for the users.

Resolver

final class Resolver {
static let `default` = Resolver()
private var container = DependencyContainer()

private init() {
}

// Register dependency
func register<Dependency>(type: Dependency.Type,
dependency: @escaping () -> Dependency) {
container.register(type: type, dependency: dependency)
}
// Resolve dependency
func resolve<Dependency>(type: Dependency.Type,
mode: DependencyStoringType = .new) -> Dependency {
container.resolve(type: type, mode: mode)
}
// Update dependency container. (it is useful for the UT)
func container(_ container: DependencyContainer) {
self.container = container
}
}

The Resolver is singleton, which means there is a single object of this in the app.

Inject

Now, we are using swift power here, by this DI makes it easy to use. We are using a property wrapper for creating the inject object. This is the bridge between the end user and the DI container.

@propertyWrapper
public struct Inject<Value> {
public private(set) var wrappedValue: Value

public init(mode: DependencyStoringType = .new) {
self.wrappedValue = Resolver.default.resolve(type: Value.self,
mode: mode)
}
}

Everything is done with the Dependency Injection here, now it’s time to use it inside the app. First, we create one struct which we need to inject

protocol User {
func userDetails() -> String
}
class UserImp: User {
func userDetails() -> String {
let addressOfObject = String(
describing: Unmanaged.passUnretained(self).toOpaque())
print(addressOfObject)
return addressOfObject
}
}

Now, we need to register our dependencies. As mentioned above we need to register it whenever the app is launched so we will get it throughout the application. For this, we create one class as below:

class DependencyRegister {
init() {
registerUseCases()
}

func registerUseCases() {
Resolver.default.register(type: User.self) {
UserImp()
}
}
}
import SwiftUI

@main
struct DependencyInjectionApp: App {
let object = AppDependency()
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct AppDependency {
init() {
DependencyRegister()
}
}

This is the demo purpose code, so I just created an object of the DependencyRegister class inside the AppDependency and it is used in DependencyInjectionApp struct.

Use of the DI inside the view

struct ContentView: View {
@Inject var viewModel1: User
@Inject var viewModel2: User
@Inject var viewModel3: User

var body: some View {
VStack {
Text(viewModel1.userDetails())
Text(viewModel2.userDetails())
Text(viewModel3.userDetails())
}
.padding()
}
}

#Preview {
ContentView()
}

Here, we are using all objects as new mode, so the output of the code is as below. where we can see 3 objects have different addresses.

// Different address of each object 
0x0000600000004950
0x0000600000004970
0x0000600000004980

Let’s try which shared mode now.

struct ContentView: View {
@Inject(mode: .shared) var viewModel1: User
@Inject(mode: .shared) var viewModel2: User
@Inject(mode: .shared) var viewModel3: User

// The remaining code same as above, we just marked the
// object as shared here.
}
// same address of each object
0x000060000000c8b0
0x000060000000c8b0
0x000060000000c8b0
Photo by Lucas Vasques on Unsplash

Now, we check how we use this inside the UT, where we need to inject mock objects instead of the originals.

class MockUser: User {
func userDetails() -> String {
return String(describing: Unmanaged.passUnretained(self).toOpaque())
}
}
final class DependencyInjectionTests: XCTestCase {
var container = DependencyContainer()
private func configure() {
container.register(type: User.self) {
MockUser()
}
Resolver.default.container(container)
}
override func invokeTest() {
super.invokeTest()
configure()
}
}

Check the configure function which is called before every test case, and in this function, we register different dependencies as per our requirement.

Tests

extension DependencyInjectionTests {
func test_Dependency() {
let object = MockView()
XCTAssertNotNil(object.userDetails())
}

func test_multipleVM_withNew_dependency() {
let object = MockView()
XCTAssertNotEqual(object.getFirstVMAddress(), object.getSecondVMAddress())
}

func test_multipleVM_withShared_dependency() {
container.remove(type: User.self)
container.register(type: User.self) {
MockUser()
}
Resolver.default.container(container)
let object = MockWithSharedInject()
XCTAssertEqual(object.getFirstVMAddress(), object.getSecondVMAddress())
XCTAssertNotEqual(object.getFirstVMAddress(), object.getThirdVMAddress())
XCTAssertNotEqual(object.getSecondVMAddress(), object.getThirdVMAddress())
}
}

UT required classes:

class MockView {
@Inject var viewModel1: User
@Inject var viewModel2: User
init() {

}

func userDetails() -> String {
viewModel1.userDetails()
}

func getFirstVMAddress() -> String {
viewModel1.userDetails()
}

func getSecondVMAddress() -> String {
viewModel2.userDetails()
}
}

class MockWithSharedInject {
func userDetails() -> String {
""
}

@Inject(mode: .shared) var viewModel1: User
@Inject(mode: .shared) var viewModel2: User
@Inject var viewModel3: User
init() {

}

func getFirstVMAddress() -> String {
viewModel1.userDetails()
}

func getSecondVMAddress() -> String {
viewModel2.userDetails()
}

func getThirdVMAddress() -> String {
viewModel3.userDetails()
}
}

Note: Also, we can create dependency injection for the weak and lazy objects.

Thanks for reading and I would appreciate your feedback on this. :)

Photo by Nachristos on Unsplash

--

--