DEV Community

Hugo Granja
Hugo Granja

Posted on

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

Introduction

In a dependency injection framework, lifetime management dictates how long an instance of a dependency should live after being created. Previously, we implemented transient lifetimes where each resolution creates a new instance. Here, we’ll introduce singleton and weak lifetimes.

Singleton

With a singleton lifetime a single instance is created and shared across the entire application.
Ideal for shared resources or objects with state that need to be accessible throughout the app and must maintain consistency.

Registration

We’ll start by defining an enum for lifetime cases.

public enum Lifetime {
    case transient
    case singleton
}
Enter fullscreen mode Exit fullscreen mode

Since each service now has a lifetime, we encapsulate this in a service object to store both the factory and the lifetime.

final class Service<T> {
    let lifetime: Lifetime
    let factory: (Container) -> T

    init(
        _ lifetime: Lifetime,
        _ factory: @escaping (Container) -> T
    ) {
        self.lifetime = lifetime
        self.factory = factory
    }
}
Enter fullscreen mode Exit fullscreen mode

To allow specifying a lifetime, we modify our register method. By default, dependencies will use the transient lifetime.

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

Resolution

To support singleton instances, we add a dictionary to cache them. When resolving a singleton, we check the cache, if the instance isn’t there, we create it, store it, and return it.

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

    ...

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

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

        switch service.lifetime {
        case .transient:
            return service.factory(self)

        case .singleton:
            if let instance = singletonServices[key] {
                return instance as! T
            }
            else {
                let instance = service.factory(self)
                singletonServices[key] = instance
                return instance
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Weak

A weak lifetime creates a dependency only once, then holds it weakly. If no other objects retain it, it’s deallocated. This approach is useful for dependencies that should persist only if actively used.

We first add the lifetime case for weak dependencies:

public enum Lifetime {
    case transient
    case singleton
    case weak
}
Enter fullscreen mode Exit fullscreen mode

To attain a weakly held value a common approach is to wrap instances in a weak container, like this:

final class WeakBox<T: AnyObject> {
    weak var value: T?

    init(value: T) {
        self.value = value
    }
}
Enter fullscreen mode Exit fullscreen mode

While WeakBox allows the value to become nil when unreferenced, storing it in a dictionary retains the WeakBox itself, requiring custom clean-up like traversing the dictionary every resolution looking for nil values and removing them.
Instead, Foundation’s NSMapTable can weakly store values and it automatically clears out references when objects are no longer in use.

Note: NSMapTable doesn’t guarantee immediate deallocation, only eventual release, likely for performance reasons. If immediate cleanup is critical, consider implementing a custom dictionary that uses WeakBox with periodic clean-up of nil values.

Create an NSMapTable to weakly store dependencies:

private let weakServices: NSMapTable<NSString, AnyObject> = .strongToWeakObjects()
Enter fullscreen mode Exit fullscreen mode

Update resolution to check weakServices for weakly-held instances:

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

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

    switch service.lifetime {
    case .transient:
        return service.factory(self)

    case .singleton:
        if let instance = singletonServices[key] {
            return instance as! T
        }
        else {
            let instance = service.factory(self)
            singletonServices[key] = instance
            return instance
        }

    case .weak:
        let key = NSString(string: key)

        if let instance = weakServices.object(forKey: key) {
            return instance as! T
        }
        else {
            let instance = service.factory(self) as AnyObject
            weakServices.setObject(instance, forKey: key)
            return instance as! T
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary

This post introduced two more dependency lifetimes: singleton and weak. Future posts will cover how to create a utility method for automatically registering dependencies using their initializers as well as how to provide arguments only available at runtime when resolving dependencies.

Top comments (0)