DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Dependency Injection Beyond Basics: Stop Letting Hilt Modules Become Code Smells

What We Will Build

In this workshop, we will take a bloated Hilt module — the kind hiding in every mid-sized Android project — and refactor it step by step. By the end, you will have a clean, split module structure, a unit test that needs zero framework boilerplate, and a working kotlin-inject component ready for Kotlin Multiplatform. Let me show you a pattern I use in every project.

Prerequisites

  • An Android project using Hilt (or familiarity with it)
  • Kotlin 1.9+ and KSP configured in your build
  • Basic understanding of constructor injection

Step 1: Audit the Monolith Module

Open your biggest Hilt module. If it looks anything like this, we have work to do:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideOkHttpClient(): OkHttpClient { /* ... */ }

    @Provides @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit { /* ... */ }

    @Provides @Singleton
    fun provideUserApi(retrofit: Retrofit): UserApi { /* ... */ }

    @Provides @Singleton
    fun provideOrderApi(retrofit: Retrofit): OrderApi { /* ... */ }

    // ...15 more provides functions
}
Enter fullscreen mode Exit fullscreen mode

Count the @Provides functions. If a single module has more than 8-10 bindings, split it by feature boundary. Here is the minimal setup to get this working — create one module per domain concern:

@Module
@InstallIn(SingletonComponent::class)
object HttpModule {
    @Provides @Singleton
    fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()

    @Provides @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit =
        Retrofit.Builder().client(client).baseUrl(BASE_URL).build()
}

@Module
@InstallIn(SingletonComponent::class)
object UserModule {
    @Provides @Singleton
    fun provideUserApi(retrofit: Retrofit): UserApi =
        retrofit.create(UserApi::class.java)
}
Enter fullscreen mode Exit fullscreen mode

Smaller modules mean faster incremental builds and a graph you can actually reason about.

Step 2: Write One Test Without Your DI Framework

This is the exercise that changes minds. Pick a repository class, and instantiate it with fakes — no Hilt, no rules, no annotations:

class UserRepositoryTest {
    private val api = FakeUserApi()
    private val cache = InMemoryUserCache()
    private val repository = UserRepository(api, cache)

    @Test fun `returns cached user when available`() {
        cache.store(User("1", "Alice"))
        val result = repository.getUser("1")
        assertEquals("Alice", result.name)
    }
}
Enter fullscreen mode Exit fullscreen mode

Compare that to the Hilt version that spins up the entire component graph just to test one class. If the manual version is simpler and faster — and it will be — that tells you exactly how much value the framework adds to your test suite.

Step 3: Set Up kotlin-inject for a Shared Module

If your team has any KMP ambitions, Hilt blocks you immediately. Every @AndroidEntryPoint locks shared logic to a single platform. Here is how kotlin-inject solves this with compile-time safety:

// Shared module — runs on Android, iOS, Desktop, Server
@Component
@Singleton
abstract class SharedComponent {
    abstract val userRepository: UserRepository

    @Provides @Singleton
    protected fun provideUserRepository(
        api: UserApi,
        cache: UserCache
    ): UserRepository = UserRepository(api, cache)
}
Enter fullscreen mode Exit fullscreen mode

kotlin-inject generates its graph at compile time via KSP. Every binding is validated before a single line of runtime code executes. The docs do not mention this, but the mental model is nearly identical to Dagger — the migration cost is genuinely low.

Add the dependency in your shared module's build.gradle.kts:

dependencies {
    ksp("me.tatarka.inject:kotlin-inject-compiler-ksp:0.7.2")
    implementation("me.tatarka.inject:kotlin-inject-runtime:0.7.2")
}
Enter fullscreen mode Exit fullscreen mode

Gotchas

Here is the gotcha that will save you hours:

  • Nullable platform types in Hilt providers. A @Provides function can return null from a Java platform type, and Hilt will inject it into a non-null Kotlin parameter without complaint. The crash surfaces three screens later. Always add explicit null checks or use Kotlin-only APIs in your providers.
  • SingletonComponent is a dumping ground. If more than 30% of your bindings live in SingletonComponent, most of them should be @ActivityRetainedScoped or @ViewModelScoped. Over-scoping wastes memory and hides lifecycle bugs.
  • Hilt tests are not unit tests. If your test class uses @HiltAndroidTest and HiltAndroidRule, you are running an integration test. That is fine when you mean to — just do not confuse it with isolated unit testing.
  • KSP does not eliminate Hilt's build cost entirely. It helps significantly over kapt, but the code generation pipeline still fires on every @Module. On a 200-module project, we measured 18 seconds added to every incremental build.

Quick Decision Guide

Your situation Recommended approach
Android-only, small team, < 20 bindings Manual DI with an AppContainer class
Android-only, large team, needs standards Hilt with strict module boundaries
Targeting KMP with shared business logic kotlin-inject or Koin
Build speed is the top priority Manual DI or Koin (no codegen)

Conclusion

The best dependency injection is the kind your team actually understands. Start this week: audit your largest module, write one framework-free test, and evaluate kotlin-inject for your next shared module. Frameworks should reduce complexity, not relocate it into generated code nobody debugs until production is on fire.

Resources:

Top comments (0)