Седьмой экран в приложении, и в каждом Activity уже горит строка из пяти конструкторов: клиент, кэш, хранилище, аналитика, шина событий. Сверху босс просит добавить вторую среду, и копипаста настроек расползается по коду, как трещины по стеклу. Внедрение зависимостей убирает ручную сборку: классы объявляют, что им нужно, а каркас сам соединяет граф. Dagger 2 от Google делает это на этапе компиляции, без рефлексии и сюрпризов в рантайме. Актуальная версия 2.60.1 вышла в июле 2026 года, генерация работает через KSP. Механика пригодна и для чистого Kotlin на сервере, но рождена для Android, поэтому разбор идёт с оглядкой на мобильные реалии.

Подключение в Gradle занимает несколько строк: к плагину KSP добавляют зависимость компилятора, и генерация вливается в обычную сборку.

plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("com.google.dagger:dagger:2.60.1")
    ksp("com.google.dagger:dagger-compiler:2.60.1")
}

До версии 2.48 генерация шла через kapt, и старые проекты до сих пор встречаются с ним, но для новых конфигураций выбор однозначен: KSP быстрее и не тянет за собой половину javac. Разберём механику от первой аннотации до готового графа, с кодом и типовыми ошибками.

Механика Dagger проверяет граф зависимостей на этапе компиляции

Идея Dagger проста: по аннотациям процессор на этапе сборки пишет обычный Java или Kotlin код, который собирает объекты вручную, так же как это сделал бы человек. Никакой рефлексии в рантайме, никакого поиска классов по пути. Если забыть поставить поставщика для строки авторизации, проект просто не соберётся, и ошибка проявится в терминале сборки, а не у пользователя на телефоне в три часа ночи.

Контраст с рефлексивными каркасами принципиален. У них граф строится при старте приложения, каждая ошибка связи стоит миллисекунды запуска, а диагностика зависит от сообщений рантайма. У Dagger граф проверен компилятором, старт не тратит время на обход классов, а сгенерированные классы открываются в среде и читаются как обычный код. За это платят временем компиляции: на больших проектах генерация занимает секунды, и это честная цена за предсказуемость.

Базовый набор аннотаций невелик и закрывает весь граф:

  1. @Inject помечает конструктор, поле или метод, через которые проходит значение;
  2. @Module и @Provides описывают поставщика, когда объект нельзя собрать самим классом;
  3. @Component связывает модули с точками внедрения и порождает генератор;
  4. @Singleton и произвольные области задают время жизни значения;
  5. @Qualifier и @Named различают нескольких поставщиков одного типа;
  6. @BindsInstance подсовывает в граф значение, созданное вне его.

Аннотации стандартизованы спецификацией JSR-330, поэтому классы с @Inject спокойно переезжают между Dagger и другими каркасами. Свежие версии понимают и классический пакет javax.inject, и современный jakarta.inject, что помогает при постепенном обновлении зависимостей.

Модули и Provides описывают поставщиков значений для графа

Простейший случай Dagger решает одной строкой. Конструктор с @Inject превращает класс в самодостаточный узел графа, и для его создания ничего больше не нужно:

class PriceFormatter @Inject constructor(
    private val locale: Locale,
    private val settings: SettingsStore,
)

class SettingsStore @Inject constructor(
    private val prefs: SharedPreferences,
)

Чего Dagger не умеет, так это угадывать, откуда взять интерфейс или класс чужой библиотеки. Для этого пишут модуль: объект с методами @Provides, каждый из которых знает, как собрать конкретное значение.

@Module
object NetworkModule {

    @Provides
    @Singleton
    fun provideHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(10, TimeUnit.SECONDS)
            .addInterceptor(LoggingInterceptor())
            .build()
    }

    @Provides
    fun provideApi(client: OkHttpClient): CatalogApi {
        return Retrofit.Builder()
            .baseUrl("https://api.example/")
            .client(client)
            .build()
            .create(CatalogApi::class.java)
    }
}

Обратите внимание: метод provideApi сам принимает клиент как параметр, и Dagger догадается передать результат предыдущего провайдера. Писать ручные вызовы не нужно, граф собирается сам.

Связка интерфейса с классом выглядит ещё скромнее:

@Module
abstract class RepositoryModule {
    @Binds
    abstract fun bindCatalogRepo(
        impl: CatalogRepositoryImpl
    ): CatalogRepository
}

Абстрактный модуль с @Binds не создаёт лишнего кода и коротко читается, а когда выбор зависит от настройки, возвращаются к @Provides.

Отдельный приём, который любят архитекторы, это мультисвязки. Провайдер с @IntoSet кладёт значение в общий набор, и граф сам собирает всех участников:

@Provides @IntoSet
fun provideAnalyticsA(): AnalyticsSink = FirebaseSink()

@Provides @IntoSet
fun provideAnalyticsB(): AnalyticsSink = DebugSink()

Получателю достаточно объявить Set из AnalyticsSink, и новые участники подключаются одной строчкой без правки потребителя. Вариант @IntoMap с ключом делает то же самое для словаря.

Компонент собирает граф и генерирует фабрику приложения

Модули описывают отдельных поставщиков, а компонент склеивает их в целое и объявляет точки входа. После сборки Dagger генерирует класс с префиксом, и приложение создаёт граф один раз, в классе Application.

@Singleton
@Component(modules = [NetworkModule::class, StorageModule::class])
interface AppComponent {
    fun inject(app: ShopApp)
    fun catalogViewModel(): CatalogViewModel.Factory
}

class ShopApp : Application() {
    lateinit var component: AppComponent
        private set

    override fun onCreate() {
        super.onCreate()
        component = DaggerAppComponent.create()
        component.inject(this)
    }
}

Методы inject без возврата помечают, куда именно вставлять значения, а методы с возвратом выдают узлы графа наружу, что удобно для ViewModels и воркеров. Когда в граф нужно передать значение, созданное до компонента, например сам контекст приложения, применяют @BindsInstance в билдере: компонент принимает его снаружи и раздаёт всем, кому нужен контекст.

Для экранов с собственной областью применяют подкомпоненты: компонент внутри компонента получает значения родителя и добавляет свои. Вход в подкомпонент открывается фабрикой родителя, и так строится дерево областей: приложение, вошедший пользователь, конкретный мастер настройки.

Области видимости управляют временем жизни объектов в графе

Аннотация @Singleton в Dagger означает не глобальность, а привязку к конкретному компоненту. Один компонент на приложение даёт классический синглтон, а отдельный компонент на экран даёт экземпляр, живущий ровно столько, сколько экран. Никакой магии: область это просто метка соответствия между компонентом и провайдером.

Помимо встроенного синглтона определяют свои области:

@Scope
@Retention(AnnotationRetention.BINARY)
annotation class LoggedInScope

Область вешают и на провайдера, и на компонент, и тогда все значения внутри живут по часам этого компонента. Для отложенного создания пригодны обёртки: Provider<T> отдаёт новый экземпляр при каждом обращении, Lazy<T> создаёт один раз при первом запросе. Обёртки также лечат циклические связи: пара объектов, ссылающихся друг на друга через Provider, соберётся без ошибки, а прямой цикл компилятор честно забракует. Для значений, известных только при создании, есть assisted-инъекция: часть приносит граф, часть передаёт создатель через фабрику, и это убирает соблазн держать изменяемое состояние в общем экземпляре.

Квалификаторы различают поставщиков одного типа

Когда в графе два экземпляра одного типа, например клиент для открытой сети и клиент с токеном авторизации, без подсказок компилятор вправе растеряться. Квалификатор ставит метку на провайдера и на точку внедрения, и метки должны совпасть.

@Qualifier
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
annotation class AuthClient

@Module
object AuthModule {
    @Provides
    @AuthClient
    fun provideAuthClient(base: OkHttpClient): OkHttpClient {
        return base.newBuilder()
            .addInterceptor(TokenInterceptor())
            .build()
    }
}

Встроенный @Named решает ту же задачу строками, но опечатка в строке не ловится типами и всплывает только при сборке, поэтому собственный квалификатор в свежем коде считается лучшим тоном: он проверяется компилятором целиком. Квалификаторы бесплатны, это просто аннотации без кода, поэтому их заводят на каждый смысловой случай, а не только на конфликт типов.

Hilt убирает рутину компонентов из Android проектов

Голый Dagger в Android требует немалой обвязки: компонент в Application, инъекция в каждом Activity, отдельные графы для ViewModel и Service, вся бухгалтерия вручную. Hilt, обёртка над тем же движком, стандартизирует эту рутину и входит в официальные рекомендации Google. Версии Hilt следуют версиям Dagger, так что и здесь актуальна 2.60.1.

Три аннотации закрывают стандартное приложение: @HiltAndroidApp на Application включает граф, @AndroidEntryPoint на Activity, Fragment и View открывает точки внедрения, @HiltViewModel с @Inject в конструкторе ViewModel убирает фабрики. Модули получают @InstallIn с одним из стандартных компонентов: SingletonComponent на всё приложение, ActivityRetainedComponent, живущий через повороты, ViewModelComponent, ViewComponent, ServiceComponent и BroadcastReceiverComponent для приёмников. Для классов вне стандартных точек входа, вроде стороннего SDK, существует @EntryPoint, достающий узел графа вручную.

@HiltAndroidApp
class ShopApp : Application()

@AndroidEntryPoint
class CatalogActivity : AppCompatActivity() {
    @Inject lateinit var priceFormatter: PriceFormatter
}

@HiltViewModel
class CatalogViewModel @Inject constructor(
    private val repository: CatalogRepository,
) : ViewModel() {
    val items = repository.observeCatalog()
}

Ранняя прослойка dagger.android с ручными инъекциями через HasAndroidInjector ныне не рекомендуется, и новые проекты строят сразу на Hilt, оставляя голый Dagger серверной разработке. Под капотом это тот же генератор, те же аннотации и та же проверка графа на компиляции, разница только в готовой раскладке компонентов под жизненный цикл Android.

Тестирование и типовые ошибки графа зависимостей

Главный подарок Dagger тестам в том, что граф это обычные классы, которые подменяются модулями. Для локальных прогонов пишут тестовый модуль с фейковым API, возвращающим заготовленные ответы, и собирают компонент с ним. В Hilt для этого есть @BindValue и замена модулей прямо в тесте через HiltAndroidRule, а инструментальные прогоны поднимают приложение на тестовом графе. Модульные тесты классов с @Inject в конструкторе вообще не требуют графа: зависимости передаются руками как обычные параметры, и это часто самый быстрый способ проверить логику.

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [NetworkModule::class],
)
object FakeNetworkModule {
    @Provides
    @Singleton
    fun provideApi(): CatalogApi = FakeCatalogApi()
}

Типовые ошибки повторяются от проекта к проекту. Сообщение о недостающей привязке читается внимательно: в нём назван тип и путь от точки внедрения, и этого достаточно, чтобы найти место, где забыли модуль. Циклы лечатся Provider или перепланировкой графа. Область, повешенная на провайдера без такой же области на компоненте, не соберётся с понятной ошибкой. Инъекция в класс, о котором компонент не знает, требует EntryPoint. А самая коварная ошибка это не техническая, а смысловая: граф собрался, но синглтон, о котором все договорились, тихо оказался не синглтоном из-за второго компонента.

Итог для Android-практика. Dagger 2 даёт граф, проверенный компилятором, без единой строчки рефлексии и без рантайм-тормозов, а Hilt делает его удобным для приложений с их Activity, ViewModel и Service. Стоимость входа это пара вечеров на аннотации и генерацию, а отдача начинается с первого крупного рефакторинга, когда смена одного провайдера автоматически протекает по всему приложению, не требуя править десяток конструкторов руками.