Unplugged DI - DI.Y Basics

October 6, 2024 | 9 min read
  • #  android
  • #  kotlin
  • #  dependency injection
  • Intoduction

    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.

    Why?

    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:

    What’s instead?

    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.

    Mental model

    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.

    Modules

    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.

    Modules

    Contract

    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:

    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.

    Dependencies

    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,
        ) : RssReaderModule {
            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:

    1. It keeps the constructor clean, readable, and maintainable;
    2. It establishes two layers of dependency management: higher-layer dependencies between modules, and lower-layer dependencies between instances.

    Dependencies

    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.

    Dependency Injection

    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,
        ) : RssReaderModule {
    
            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.

    Launch it!

    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")
        }
    }
    

    Wrap up

    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:

    1. Injection for Android: App, Activity, Fragment, Service, ContentProvider etc.
    2. Injection for Multiplatform: platform-specific implementations
    3. Module initialization
    4. Naming and organization of module interfaces
    5. Writing test modules
    6. Functions for providing in maps and lists
    7. Suggest your topic on 𝕏

    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!