Creating a simple dependency injection framework in Swift [Part 4]: Dynamic arguments

hbg

Hugo Granja

Posted on November 6, 2024

Creating a simple dependency injection framework in Swift [Part 4]: Dynamic arguments

Part 3

Introduction

Some objects need parameters only available at runtime. For example, a UserService might require a userId that's known only after a user logs in. To handle such cases we should support dynamic injection when resolving instances.

Registration

Using Swift's parameter packs, we can let our factory closures specify any arguments needed when registering services.

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

    init(
        _ lifetime: Lifetime,
        _ factory: @escaping (Container, repeat each A) -> T
    ) {
        self.lifetime = lifetime
        self.factory = factory
    }
}

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

Resolution

Upon resolution we expect to be given the same arguments that were previously specified on registration to create an instance of that type.

public func resolve<T, each A>(
    _ type: T.Type,
    arguments: repeat each A
) throws -> T {
    let key = String(describing: type)

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

    switch service.lifetime {
    case .transient:
        return service.factory(self, repeat each arguments)

    ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage

Assuming userId is a string, here’s how to register and resolve services:

let container = Container()

container.autoRegister(
    DatabaseService.self,
    using: DatabaseServiceImpl()
)

container.register(UserService.self) { container, userId in
    UserServiceImpl(
        databaseService: container.resolve(DatabaseService.self),
        userId: userId
    )
}

container.resolve(UserService.self, arguments: "1234")
Enter fullscreen mode Exit fullscreen mode

To reduce coupling, classes requiring UserService should not directly reference the container. Instead, inject a closure to create UserService, as demonstrated below with UserJourneyFactoryImpl.

final class UserJourneyFactoryImpl: UserJourneyFactory {
    private let userServiceFactory: (String) -> UserService

    init(
        userServiceFactory: @escaping (String) -> UserService
    ) {
        self.userServiceFactory = userServiceFactoryViewFactory
    }

    func makeUserService(userId: String) -> UserService {
        return userServiceFactory(userId)
    }
}

container.register(UserJourneyFactory.self) { r in
    UserJourneyFactoryImpl(
        userServiceFactory: { userId in
            r.resolve(UserService.self, arguments: userId)
        }
    )
}
Enter fullscreen mode Exit fullscreen mode

This setup allows, for example, a Coordinator to use UserJourneyFactory to create user-specific journeys in the app. Alternatively, you could inject the factory closure directly into the Coordinator.

Explore the code

Check out the full implementation of this library on BackpackDI on GitHub.

For a practical example, see MobileUseCases on GitHub.
I'd recommend looking at the entry point of the app MUCApp.swift and AppContainer.swift inside the DependencyInjection folder for a demonstration on how to centralize the configuration of the container and establish a composition root.

That’s it for this guide for now!

Let me know if you’d like to see other features or examples in the comments.

💖 💪 🙅 🚩
hbg
Hugo Granja

Posted on November 6, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related