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
}
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)
}
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)
}
}
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)
}
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")
}
Gotchas
Here is the gotcha that will save you hours:
-
Nullable platform types in Hilt providers. A
@Providesfunction can returnnullfrom 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. -
SingletonComponentis a dumping ground. If more than 30% of your bindings live inSingletonComponent, most of them should be@ActivityRetainedScopedor@ViewModelScoped. Over-scoping wastes memory and hides lifecycle bugs. -
Hilt tests are not unit tests. If your test class uses
@HiltAndroidTestandHiltAndroidRule, 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)