Creating a simple dependency injection framework in Swift [Part 1]

Hugo Granja - Nov 2 - - Dev Community

Introduction

Dependency Injection is a fundamental pattern in software architecture that helps manage dependencies between components, leading to modular, testable, and maintainable code. In this post, we’ll explore design considerations and implement essential components.

The two main functions of a DI system are registration and resolution:

  • Registration defines which dependencies should be created and how they are instantiated.
  • Resolution retrieves instances of these registered dependencies.

Registration

To register dependencies, we could store each instance in a dictionary, using its type as the key.

public final class Container {
    private var services: [String: Any] = [:]

    func register<T>(_ type: T.Type, _ instance: T) {
        let key = String(describing: type)
        services[key] = instance
    }
}
Enter fullscreen mode Exit fullscreen mode

But to conserve memory and improve startup time, we can delay instantiation until a dependency is actually needed. This is particularly beneficial for dependencies that are rarely used or expensive to initialize. Instead of directly storing instances, we store closures that build instances when called. This pattern is known as lazy instantiation.

func register<T>(_ type: T.Type, _ factory: @escaping () -> T) {
    let key = String(describing: type)
    services[key] = factory
}
Enter fullscreen mode Exit fullscreen mode

Resolution

To resolve a dependency, we retrieve the registered closure from our dictionary and call it to create an instance. Since there’s no guarantee a dependency has been registered, and stored values are of type Any, we need to cast and handle errors accordingly.

We could choose to return an optional or throw an error, for demonstration purposes and since such issues are easy to identify during development, we will call fatalError if a requested dependency hasn’t been registered, this also keeps our resolution code clean and free from error-handling clutter.

public final class Container {
    private var services: [String: Any] = [:]

    public init() {}

    public func register<T>(
        _ type: T.Type,
        _ factory: @escaping () -> T
    ) {
        let key = String(describing: type)
        services[key] = factory
    }

    public func resolve<T>(
        _ type: T.Type
    ) -> T {
        let key = String(describing: type)

        guard
            let service = services[key] as? () -> T
        else {
            fatalError("Service for type \(type) not found!")
        }

        return service()
    }
}
Enter fullscreen mode Exit fullscreen mode

Each time we resolve a dependency, a new instance is created. While this approach works well for many cases, it doesn’t cover all use cases. For example, singletons or shared resources might need to persist across resolutions. In future posts, we’ll discuss handling different dependency lifetimes, allowing more flexibility for shared or long-lived dependencies.

Using our DI container in an application

Now that we have our DI container set up, let’s see how we can use it in a simple application. We will create a couple of services and demonstrate how to register and resolve them using our container.

Step 1: Define some services

First, let’s define two services: DatabaseService and UserService. The DatabaseService will simulate a database connection, while the UserService will depend on DatabaseService to fetch user information.

protocol DatabaseServiceProtocol {
    func fetchUser() -> String
}

class DatabaseService: DatabaseServiceProtocol {
    func fetchUser() -> String {
        return "John Doe"
    }
}

class UserService {
    private let databaseService: DatabaseServiceProtocol

    init(databaseService: DatabaseServiceProtocol) {
        self.databaseService = databaseService
    }

    func getUser() -> String {
        return databaseService.fetchUser()
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Register services in the DI Container

Next, we will register these services in our DI container. We will use closures to create instances of DatabaseService and UserService.

let container = Container()

container.register(DatabaseServiceProtocol.self) {
    DatabaseService()
}

container.register(UserService.self) {
    UserService(
        databaseService: container.resolve(DatabaseServiceProtocol.self)
    )
}
Enter fullscreen mode Exit fullscreen mode

In the above example, you might have noticed that by capturing container in the closure for UserService we unintentionally create a retain cycle, container holds a strong reference to the closure, and the closure holds a strong reference back to container. To avoid this, we can modify the closure to accept a Container as a parameter, eliminating the strong capture.

public func register<T>(
    _ type: T.Type,
    _ factory: @escaping (Container) -> T
) {
    let key = String(describing: type)
    services[key] = factory
}

public func resolve<T>(
    _ type: T.Type
) -> T {
    let key = String(describing: type)

    guard
        let service = services[key] as? (Container) -> T
    else {
        fatalError("Service for type \(type) not found!")
    }

    return service(self)
}
Enter fullscreen mode Exit fullscreen mode

With this change, our registration now looks like this:

let container = Container()

container.register(DatabaseServiceProtocol.self) { _ in
    DatabaseService()
}

container.register(UserService.self) { container in
    UserService(databaseService: container.resolve(DatabaseServiceProtocol.self))
}
Enter fullscreen mode Exit fullscreen mode

We could further conform Container to a Resolver protocol and use Resolver as the argument type. This limits exposure to just the resolve method, keeping other methods hidden.

Step 3: Resolve and use the services

Now that we have registered our services, we can resolve and use them in our application.

let userService: UserService = container.resolve(UserService.self)
let user = userService.getUser()
...
Enter fullscreen mode Exit fullscreen mode

Summary

This post demonstrated how to build a simple DI container in Swift, register dependencies, and resolve them in a straightforward way. In future posts, we’ll expand our DI container to support additional dependency lifetimes and more complex configurations.

.
Terabox Video Player