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