Cómo Hilt cruza los límites de módulos
Hilt genera código en tiempo de compilación. Cuando tenés múltiples módulos de Gradle, el grafo de Hilt se construye a partir de todos los @Module que están en el classpath del módulo que contiene @HiltAndroidApp — es decir, :app.
La implicación práctica: un @Module declarado en :feature:checkout con @InstallIn(SingletonComponent::class) contribuye al grafo global de Hilt igual que si estuviera en :app. :app no necesita saber nada sobre ese módulo — simplemente lo incluye al tener :feature:checkout como dependencia.
Módulo Hilt por feature
// :feature:checkout/di/CheckoutModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class CheckoutModule {
// Hilt registra el binding — :app no sabe que existe CheckoutRepositoryImpl
@Binds
@Singleton
abstract fun bindCheckoutRepository(
impl: CheckoutRepositoryImpl
): CheckoutRepository
}
// :feature:checkout/presentation/CheckoutViewModel.kt
@HiltViewModel
class CheckoutViewModel @Inject constructor(
private val procesarPago: ProcesarPagoUseCase, // de :core:domain
private val checkoutRepo: CheckoutRepository // provisto por CheckoutModule
) : ViewModel()
// El Fragment de checkout usa el ViewModel con injection normal:
@AndroidEntryPoint
class CheckoutFragment : Fragment() {
private val viewModel: CheckoutViewModel by viewModels()
}
:app solo necesita incluir el módulo de GradlePara que el CheckoutModule forme parte del grafo de Hilt, :app solo necesita implementation(project(":feature:checkout")) en su build.gradle. No hay que registrar nada explícitamente en :app.
Módulos de Hilt en core
// :core:network/di/NetworkModule.kt
// Todos los módulos que dependen de :core:network pueden inyectar OkHttpClient
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
}
}
// :core:database/di/DatabaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "app_db")
.build()
}
@Provides
fun provideProductoDao(db: AppDatabase): ProductoDao = db.productoDao()
}
Features que se necesitan sin conocerse
El caso más común que parece un problema pero tiene solución limpia: :feature:checkout necesita saber el estado del carrito que gestiona :feature:productos, pero no puede importar :feature:productos.
// Solución: la interfaz vive en :core:domain, cada feature la implementa/consume
// :core:domain/CarritoRepository.kt
interface CarritoRepository {
fun getCarrito(): Flow<Carrito>
suspend fun agregar(producto: Producto, cantidad: Int)
suspend fun vaciar()
}
// :feature:productos/data/CarritoRepositoryImpl.kt
// La implementación está en la feature que gestiona el carrito
class CarritoRepositoryImpl @Inject constructor(
private val dao: CarritoDao
) : CarritoRepository {
override fun getCarrito() = dao.observarCarrito().map { it.toDomain() }
override suspend fun agregar(producto: Producto, cantidad: Int) { /* ... */ }
override suspend fun vaciar() { dao.vaciar() }
}
// :feature:productos/di/CarritoModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class CarritoModule {
@Binds @Singleton
abstract fun bindCarritoRepository(impl: CarritoRepositoryImpl): CarritoRepository
}
// :feature:checkout/presentation/CheckoutViewModel.kt
// Checkout puede inyectar CarritoRepository porque está en :core:domain
// No sabe nada de CarritoRepositoryImpl ni de :feature:productos
class CheckoutViewModel @Inject constructor(
private val carrito: CarritoRepository, // interfaz de :core:domain ✓
private val procesarPago: ProcesarPagoUseCase
) : ViewModel()
@EntryPoint — cuando no podés inyectar directamente
Hay casos donde necesitás una dependencia del grafo de Hilt pero no podés usar @Inject constructor — por ejemplo, en un ContentProvider, en un BroadcastReceiver manual, o en código que no es un componente de Android:
// Definir el entry point en el módulo que necesita la dependencia
@EntryPoint
@InstallIn(SingletonComponent::class)
interface NetworkEntryPoint {
fun okHttpClient(): OkHttpClient
}
// Usarlo desde código que no puede recibir @Inject:
class MiContentProvider : ContentProvider() {
override fun onCreate(): Boolean {
val entryPoint = EntryPointAccessors.fromApplication(
context!!.applicationContext,
NetworkEntryPoint::class.java
)
val okHttpClient = entryPoint.okHttpClient()
return true
}
}
Scopes entre módulos — qué scope usar dónde
// @Singleton — una sola instancia en toda la app
// Usar para: repositorios, clientes de red, base de datos
// El scope vive en SingletonComponent (el Application)
// @ViewModelScoped — una instancia por ViewModel
// Usar para: use cases, helpers que el ViewModel necesita
// El scope vive en ViewModelComponent
// Sin scope (unscoped) — nueva instancia cada vez
// Usar para: objetos baratos, transformadores, mappers
// Regla práctica en multimodulo:
// Los módulos :core proveen @Singleton (repositorios, clientes)
// Los módulos :feature proveen @ViewModelScoped o sin scope
// Nunca @ActivityScoped en un módulo :core — crea dependencias implícitas de lifecycle
@Module
@InstallIn(ViewModelComponent::class) // solo vive mientras el ViewModel existe
object CheckoutUseCasesModule {
@Provides
@ViewModelScoped
fun provideProcesarPagoUseCase(repo: CheckoutRepository) =
ProcesarPagoUseCase(repo)
}