It all began with impatience. As I started a new project, I dreaded the prospect of selecting and configuring a dependency injection (DI) framework yet another time. Eager to dive straight into implementing the app, I decided to forgo the framework entirely. Here’s how that decision played out.
In the past, I’ve worked with various DI frameworks (Dagger, Hilt and Koin) and even created Magnet DI library 🙈, featuring automatic scoping detection and runtime scope inspection. However, I was never completely satisfied with any of them. Here are the key drawbacks observed:
My ideal DI framework, if one were to exist, would have the following attributes:
Ambitious? No doubts. With these thoughts in mind, and recalling this excellent post from the past (the topic is not new, after all), let’s dive in.
The first step is to clearly outline the structure of our application. We need to identify its components and understand how they interact with one another. This will help us manage the dependencies effectively.
Modern apps often face challenges with increasing complexity. To manage this, developers usually break their applications into functional modules. These aren’t the same as project or Gradle modules, but rather abstract groups of related functionality. These functional modules can either be placed in separate Gradle modules or organized within different packages of the same Gradle module.
For example, consider an RSS reader app. The “RSS reader” module, which handles the main business logic, would rely on a “network” module for fetching RSS feeds and a “database” module for storing them locally. In this setup, the “RSS reader” module depends on both the “network” and “database” modules.
Up to this point, functional modules have been informal groupings of related functionality. But what if we formalize this natural separation and turn it into a contract? We can define a module interface for each group. This interface will serve as a contract between modules, outlining the functions a module offers to others.
Here’s how this could work in our RSS reader app:
interface NetworkModule {
val httpClient: HttpClient // (1)
}
interface DatabaseModule {
val database: Database // (2)
}
interface RssReaderModule {
interface FetchRss {
suspend operator fun invoke(url: String)
}
fun fetchRss(): FetchRss // (3)
}
For those familiar with DI frameworks, these interfaces might already look familiar. Let me clarify the contract we’ve created:
FetchRss
interface declared inside the “RSS reader” module is here just for demo purposes. It can be declared somewhere in the module, not necessarily inside the module interface.fetchRss()
function (3) is a factory function. Each time the function is called, it creates a transient instance of the FetchRss
type. Transient means here that its lifespan isn’t bound to the lifespan of the module and is controlled by the consumer of the instance.You may have noticed the absence of the “create” prefix in the factory method. This is intentional. Since all functions within a module interface are factory methods, omitting the prefix keeps the interface cleaner and more readable, making it easier to use.
Now that the contracts are defined, it’s time to implement them. This will reveal all dependencies between modules by making them explicit.
Module implementation primarily involves creating the necessary instances — there’s no business logic here. The only logic allowed should be related to instance creation and lifecycle management, such as lazy instantiation for caching. To keep things simple and reduce file clutter, the implementation is placed as an inner type within the module interface itself.
interface NetworkModule {
val httpClient: HttpClient
class Default : NetworkModule { // (1)
override val httpClient: HttpClient by lazy ...
}
}
interface DatabaseModule {
val database: Database
class Default : DatabaseModule { // (2)
override val database: Database by lazy ...
}
}
interface RssReaderModule {
fun fetchRss(): FetchRss
class Default( // (3)
private val networkModule: NetworkModule,
private val databaseModule: DatabaseModule,
) : UserModule {
override fun fetchRss(): FetchRss ...
}
}
Using the by lazy
construct allows for the lazy initialization of modules, which can improve startup time. If needed, the val-properties can be initialized immediately.
It’s important to note that a module should only depend on other modules, not on specific instances provided by them. This serves two key purposes:
The lower-layer dependencies are only permitted within their respective higher-layer inter-module dependencies.
This approach is extremely helpful for understanding and managing the overall structure of the application. By focusing on the higher-layer first, you already know what dependencies your instances do or don’t have. Additionally, with moderate effort, this setup allows for easy code analysis and the visualization of module dependencies in a diagram.
We’ve reached the fun part — actual dependency injection. Let’s implement the FetchRss
interface and inject the HttpClient
and Database
dependencies.
class DefaultFetchRss( // (1)
private val httpClient: HttpClient,
private val database: Database,
) : FetchRss {
override suspend fun invoke(url: String) {
val rss = httpClient.get(url)
database.upsert(rss)
}
}
interface RssReaderModule {
fun fetchRss(): FetchRss
class Default(
private val networkModule: NetworkModule,
private val databaseModule: DatabaseModule,
) : UserModule {
override fun fetchRss(): FetchRss =
DefaultFetchRss( // (2)
httpClient = networkModule.httpClient,
database = databaseModule.database,
)
}
}
There’s nothing complicated here; the process is straightforward. The implementation’s constructor (1) takes HttpClient
and Database
instances as parameters. The fetchRss()
factory method (2) retrieves these dependencies from their respective modules and passes them to the constructor. That’s all.
Any other instance will be injected in exactly the same way. The only difference is where the instance is sourced from.
Field injection and method injection are neither supported nor recommended. Field injection breaks encapsulation, while method injection relies on
lateinit
, which can lead to runtime failures. The only proper way to inject instances is through the constructor.
If you encounter a circular dependency, the first solution is to add another module interface to break the circle. If that’s not feasible, you can use a provider function as a parameter, such as val httpClient: () -> HttpClient
.
Now it’s time to bring everything together and call the FetchRss
function from our app. Speaking of which, let’s create an “application” module that holds all the other modules. This will serve as our “assemblage point”, as Don Juan would say.
interface AppModule {
val rssReaderModule: RssReaderModule
class Default : AppModule {
override val rssReaderModule: RssReaderModule by lazy {
RssReaderModule.Default(
networkModule = networkModule,
databaseModule = databaseModule,
)
}
// private modules
private val networkModule: NetworkModule by lazy {
NetworkModule.Default()
}
private val databaseModule: DatabaseModule by lazy {
DatabaseModule.Default()
}
}
}
Using the module hierarchy we’ve built is as simple as this:
fun main() {
val appModule = AppModule.Default()
val fetchRss = appModule.rssReaderModule.fetchRss()
runBlocking {
fetchRss("https://www.halfbit.de/index.xml")
}
}
There’s much more to explore about this approach, but I’ll save that for another time. Thank you for making it this far — you’re awesome! I truly appreciate your time and hope you found something valuable. This concludes part I — stay tuned for more and happy coding 🖖
Upcoming topics:
p.s. ✌️ If you need support for your team or product, feel free to DM me on 𝕏 or contact me via email. I’m open to new projects!