Граф зависимостей KMP‑приложения можно собрать обычным Kotlin‑кодом — без рефлексии, кодогенерации, аннотаций и DSL. Composition root и конструкторы, никакой магии. Звучит как шаг назад от зрелых DI‑фреймворков — пока не посмотришь, кому такая форма удобна: тебе, новому человеку в команде и твоему ИИ‑агенту.
Сам composition root — не изобретение и не что‑то про KMP. Термин популяризировал Марк Симан (Mark Seemann), автор книги «Dependency Injection», и описал его как единственную точку, где собирается весь граф объектов приложения (ploeh.dk). Паттерн одинаково работает в.NET, на бэкенде, в Python — в любом модульном приложении, которое собирает зависимости в одной точке входа. KMP добавляет к нему одну ось, специфичную для платформы: иерархию сорс‑сетов. О ней дальше — иерархия контейнеров повторяет иерархию сорс‑сетов, и место каждого компонента определяется тем, какой API виден его сорс‑сету.
Зачем вообще ручной DI, если есть зрелые фреймворки? Главная причина — наглядность. Граф зависимостей здесь — обычный код: видно, что и где создаётся, любую связь можно пройти через «go to definition» в IDE. Никакой магии, никаких объектов «из воздуха» — то, что во фреймворке скрыто за аннотациями и кодогенерацией, лежит открытым текстом. Из наглядности вытекает остальное: нулевая стоимость входа (нет плагина, настройки сборки, версионных конфликтов), отладка обычным дебаггером по коду, низкий порог для нового человека в команде — ничего не нужно учить за пределами проекта, читается как любой Kotlin.
Это не отказ от инструмента из принципа. Фреймворк решает реальные проблемы, и на большом проекте он может окупиться. Но он же добавляет компилятор‑плагин или KSP, свой жизненный цикл сборки, свои правила и свою кривую обучения. Пока проблем, которые он решает, ещё нет, он остаётся аппаратом тяжелее задачи. Дальше — как устроен ручной DI в Tether, KMP‑приложении для передачи файлов между устройствами (Android, iOS, Desktop), и где проходит граница, за которой фреймворк начинает себя окупать.
Код в примерах синтетический: имена классов обобщены, чтобы показать форму паттерна, а не конкретную реализацию. Сами паттерны взяты из боевого кода.
Главная идея: composition root
Composition root — единственное место, где приложение собирает граф зависимостей. Это место принято называть контейнером. Контейнер единственный создаёт компоненты — все сущности графа, которыми владеет приложение: интеракторы, репозитории, источники данных — и раздаёт их всем, кому они нужны. Никто, кроме контейнера, не вызывает конструкторы компонентов.
open class AppContainer { open val httpClient: HttpClient by lazy { HttpClient() } open val messageRepository: MessageRepository by lazy { MessageRepository(httpClient) } open val syncEngine: SyncEngine by lazy { SyncEngine(messageRepository) }}
Поля здесь ленивые, но это деталь: порядок и момент создания задаёт контейнер, а не случайность обращения. Где ленивость не нужна — компонент собирается сразу; где by lazy мешает синхронизацией — берётся LazyThreadSafetyMode.NONE. Поле не обязано быть синглтоном: вместо готового объекта в контейнере может лежать фабрика, которая собирает новый экземпляр на каждый вызов.
Каждый компонент получает свои зависимости в конструктор. Класс SyncEngine не знает, откуда взялся MessageRepository, — он его принимает. Это и есть внедрение зависимостей: зависимость передаёт вызывающий.
Главное преимущество видно сразу в тестах. Раз SyncEngine принимает MessageRepository в конструктор, тест передаёт туда фейк — без моков, без рефлексии, без подмены глобалов.
Преимущества подхода
-
Наглядность. Граф зависимостей — обычный Kotlin‑код: видно, что и где создаётся, любую связь можно пройти в IDE. Никакой магии и объектов «из воздуха».
-
Честные конструкторы. Сигнатура класса — это его полный список зависимостей. Ничего не приходит из невидимого глобала.
-
Структура подсказывает, где создавать компонент. Дерево контейнеров повторяет дерево сорс‑сетов, и место компонента — там, где впервые виден нужный API (Иерархия контейнеров).
-
Тонкий интеграционный слой. Разделение на Api и Impl строго обозначает публичную поверхность модуля (Разделение на Api и Impl).
-
Нет внешних зависимостей. DI не тянет в проект ни одной сторонней библиотеки: ни плагина сборки, ни рантайм‑артефакта, ни версий, которые надо обновлять и согласовывать между собой.
-
Чужая ответственность не протекает в фичевый код. Фичевый код остаётся обычным Kotlin, без аннотаций и кодогенерации DI. Связывание живёт только в composition root, а не размазано по классам, которые оно обслуживает.
-
Правильное направление зависимостей. Компонент не знает, где его применят, и не ссылается на контекст использования. За счёт этого он переиспользуем, а всё знание о месте применения остаётся в корне.
-
Библиотеки и внутренние модули обрабатываются одинаково. Внутренний модуль и опубликованная библиотека собираются одним приёмом: Api/Impl плюс фабрика. Поэтому модуль можно без переделки вынести в отдельный репозиторий, когда его понадобится переиспользовать.
-
Дешёвый переход. Контейнеры и провайдеры по форме уже близки к графу фреймворка — переезжать можно без структурных изменений (Миграция на фреймворк).
-
Проще для ИИ‑агентов.
Ручной DI и разработка с ИИ‑агентами
У наглядности есть второй потребитель, про которого пять лет назад не думали, — кодовый ИИ‑агент. Tether развивается в связке с такими агентами, и ручной DI здесь выгоден по той же причине, что и для человека: граф зависимостей лежит в коде явным текстом, без магии фреймворка.
Когда агент правит фичу, ему нужно понять, что куда передаётся. С ручным DI он это просто читает: конструктор — честный список зависимостей, точка сборки — один файл, любую связь видно через переход к определению. Фреймворк прячет половину этого за аннотациями и кодогенерацией — агенту приходится держать в голове правила фреймворка и достраивать невидимый граф по косвенным признакам. Чем меньше магии, тем меньше агент додумывает то, чего в коде не видно.
Отсюда же — проверяемость. Свод принципов ручного DI («зависимости — в конструктор», «не доставай из глобала», «синглтон создаётся один раз в корне») — это короткий чек‑лист, по которому и человек, и агент‑ревьюер сверяют новый код. В Tether этот чек‑лист оформлен как инструкция для агентов, что считать корректным DI. Ручной подход делает её исполнимой: проверять надо обычный код, а не поведение фреймворка.
Это DI, а не service locator
Ручному DI часто возражают: на практике он вырождается в service locator — глобальный реестр, из которого классы достают зависимости сами. Для конструкторной инъекции это возражение не работает. Разница в направлении управления: при service locator класс знает про реестр и тянет зависимости из него; при инъекции через конструктор класс не знает ни про какой контейнер — зависимости в него передаёт composition root. Контейнер здесь — место сборки, и классы из него не читают. Эту границу подробно разобрал Мартин Фаулер.
Одно исключение: Android‑компоненты с пустыми конструкторами вынуждены доставать контейнер сами. Это локальный service locator на границе с чужим фреймворком — подробности в разделе «Паттерн Provider для Android».
Сюда же — частый вопрос «почему не Koin». Koin тоже обходится без кодогенерации и плагина и нативен для KMP, но его связывание по умолчанию резолвится в рантайме: зависимость достаётся из реестра по запросу, граф не виден в коде, а забытая регистрация всплывает не ошибкой компиляции, а падением в рантайме. Это ровно тот service locator с невидимым графом, от которого ручной DI и уходит.
Принципы ручного DI
Composition root держится на нескольких правилах.
Только инъекция через конструктор — компоненты сами не создают компоненты. Конструктор класса — это его честный список зависимостей. Всё, что класс достаёт из глобалов или создаёт внутри, в этом списке невидимо.
// плохо: класс сам создает и владеет другим классом, его не подменить в тестеclass SyncEngine { private val repo = SomeRepository()}// хорошо: зависимость явнаяclass SyncEngine(private val repo: SomeRepository)
Сюда же — nullable‑зависимость со значением = null по умолчанию. Если боевая сборка всегда передаёт компонент, а в сигнатуре он опциональный, то вызов, который его забыл, тихо отключит фичу без ошибки компиляции. Делайте такую зависимость обязательной. Значение по умолчанию null оставляйте только для по‑настоящему опциональной зависимости — и тогда тестируйте ветку с null.
Платформенный контекст — это зависимость, а не глобал. Точка входа уже держит Context приложения. Передайте его в конструктор, а не доставайте из статического поля.
// плохоactual class SyncEngine { private val manager = GlobalApp.context.getSystemService(/* … */)}// хорошоactual class SyncEngine(context: PlatformContext) { private val manager = context.getSystemService(/* … */)}
Один синглтон — один владелец. Если компонент задуман как синглтон, он создаётся ровно один раз — в composition root, не в отдельном object и не внутри другого компонента. А если, наоборот, нужен каждый раз свежий объект или кэшируемый по своим правилам — в контейнере лежит фабрика или реестр: они владеют правилами создания и дают удобные методы с минимумом аргументов.
Не создавайте зависимости внутри composable. Composable, который сам собирает сервис, — это composition root не на своём месте. Грубый случай очевиден: без remember на каждой рекомпозиции рождается новый экземпляр и тут же выбрасывается — утечка и мёртвый объект. Но даже аккуратный вариант с remember неверен:
@Composablefun SomeScreen() { val engine = remember { SyncEngine(SomeRepository()) } DisposableEffect(Unit) { engine.start() onDispose { engine.stop() } }}
remember убирает утечку, но не главную ошибку: composable владеет жизненным циклом синглтона, а владеть им должна точка входа. Правильная форма — собрать контейнер в точке входа и передать готовый компонент внутрь.
Контейнер хранит именованные компоненты, а не голые данные. Поле контейнера — это объект, который чем‑то владеет или управляет: хранилище, сервер, клиент, корутинный скоуп, фабрика. Не строка, не число, не порт, не тег.
Любое значение неявно несёт логику — доменную или слоя данных. Положить в контейнер готовую строку deviceId — значит зашить решение «это значение доступно сразу, всегда и всем». Но откуда оно берётся? Возможно, его надо прочитать с диска, сгенерировать при первом запуске, дождаться после сетевого ответа. Эта логика кому‑то принадлежит — компоненту, который за deviceId отвечает. Контейнер логикой не владеет, даже такой простой; он только собирает компоненты, которые ею владеют.
// плохо: контейнер владеет данными и зашивает «доступно сразу»class AppContainer { val deviceId: String = readDeviceId()}// хорошо: контейнер собирает владельца, тот отдаёт значение, когда оно готовоclass AppContainer { val identity: DeviceIdentity by lazy { DeviceIdentity(storage) }}// потребитель спрашивает identity.deviceId() в нужный момент — в том числе после I/O
Голые данные на контейнере заставляют материализовать их в момент сборки, прячут, кто за них отвечает, и превращают граф зависимостей в мешок свойств.
Никаких молча бросающих общих дефолтов. Когда поле контейнера требует платформенной реализации, общий контракт говорит об этом явно: поле abstract на общем контейнере — тогда каждый платформенный лист обязан его доопределить, иначе не скомпилируется. Конкретный общий дефолт, который бросает исключение и работает только потому, что одна платформа его случайно переопределила, — худший вариант: он компилируется, проходит тесты на фейках и падает в рантайме на той платформе, что забыла переопределить.
Иерархия контейнеров повторяет иерархию сорс‑сетов
В KMP‑приложении контейнеров целое дерево. Оно повторяет дерево сорс‑сетов. Каждый слой добавляет только то, что его сорс‑сет способен увидеть.
source set container ────────── ───────── commonMain ←→ AppContainer ├ jvmMain ←→ ├ JvmAppContainer │ ├ androidMain ←→ │ ├ AndroidAppContainer │ └ desktopMain ←→ │ └ DesktopAppContainer └ appleMain ←→ └ AppleAppContainer └ iosMain ←→ └ IosAppContainer
AppContainer в commonMain собирает всё, что выражается общим кодом. AndroidAppContainer в androidMain добавляет компоненты, которым нужен Context или другой Android‑API. IosAppContainer — то, что требует Apple‑фреймворков. Платформенный лист (конечный узел дерева, leaf) наследует общий контейнер и доопределяет недостающее.
Граница проходит по видимости API. Если компонент собирается из общих абстракций — он живёт в общем контейнере. Если ему нужен платформенный тип — он спускается в платформенный лист. Компонент создаётся там, где впервые становится виден нужный API.
На схеме промежуточный jvmMain как общий родитель androidMain и desktopMain — это настраиваемая иерархия сорс‑сетов, а не форма KMP по умолчанию: в дефолтной структуре Android и JVM лежат соседями под commonMain без общего jvm‑слоя, и совместный jvmMain включается шаблоном иерархии в Gradle.
Точки входа платформ
Сколько у платформы точек входа — столько и листьев контейнера. Каждая точка входа собирает свой лист и раздаёт компоненты вниз.
-
На Android контейнер собирается в подклассе
Applicationи живёт столько же, сколько процесс. -
На iOS контейнер собирается в точке входа UI, например в
MainViewController. -
На Desktop точек входа может быть несколько. GUI‑запуск и CLI‑запуск (
main) — две разные точки входа, и каждая собирает свой лист; на classpath каждой компиляции попадает только то, что ей нужно.
Точка входа владеет жизненным циклом, и потому скоуп созданного ей графа зависимостей ограничивается именно временем ее жизни.
Конфигурация: вход в контейнер
Контейнеру нужны значения, которые знает только точка входа: порт сервера, каталог загрузок, ссылка на Android Application. Эти значения собираются в отдельный объект конфигурации, и его иерархия повторяет иерархию контейнеров. В конфигурации лежат не только прямые значения из строк, чисел или флагов. Туда же попадают объекты со своим поведением: реализация транспорта или источника данных, набор настроек UI под версию приложения, выбор модели монетизации — B2B или B2C. И в отличие от результирующего контейнера, здесь прямые значения разрешены (подробнее в Принципах ручного DI)
// commonMaininterface AppConfig { val downloadsDir: String val port: Int}// androidMaininterface AndroidAppConfig : AppConfig { val application: Application}
Контейнер принимает конфигурацию в конструктор. AppContainer в commonMain — без аргументов; промежуточные контейнеры берут свой подтип AppConfig; листья передают конкретную конфигурацию наверх.
Конфигурация — это настройки, поступающие на вход. Контейнер — это то, что собирается с применением этих настроек. Разделение даёт ровно одну точку, где приложение задаёт параметры, и отдельную точку, где из них строится граф.
Паттерн Provider для Android
С Android есть отдельная сложность. Его системные компоненты — Activity, Service, BroadcastReceiver — создаёт фреймворк, и у них должны быть пустые конструкторы. Передать им контейнер через конструктор, как обычному классу, нельзя.
Решение, которое ложится на эту ситуацию:
-
Подкласс
Applicationреализует интерфейс‑провайдер и владеет контейнером. -
Системные компоненты достают контейнер через приведение:
(application as AppContainerProvider).container.
interface AppContainerProvider { val container: AppContainer}class MyApp : Application(), AppContainerProvider { override val container: AppContainer by lazy { AndroidAppContainer(/* config */) }}class MyService : Service() { private val container by lazy { (application as AppContainerProvider).container }}
По сути это service‑locator как вынужденная компенсация ограничения платформы. Доступ к нему ограничен только Android‑классами. Обычные Kotlin‑классы получают зависимости в конструктор. Ограничение здесь платформенное, а не специфичное для ручного DI: любой фреймворк изобретает здесь свои обходы.
Сам приём документирован Android как способ ручного DI (Manual dependency injection) — правда, там он подан как ступень перед Hilt. Здесь он остаётся самостоятельным решением до тех пор, пока не сработают триггеры из раздела про фреймворк.
Композиция контейнера
Описанная выше иерархия контейнеров собирает граф наследованием: лист достраивает то, что общий контейнер уже определил. Наследование может быть заменено или дополнено композицией. Использование композиции оправдано, когда нужно вынести контейнер по отдельной оси. Как пример — та же ось платформы. AppContainer может быть не переопределен, а принимать в конструктор платформенный контейнер параметром.
// главный контейнер собирается из фрагмента-осиclass AppContainer( private val platformContainer: PlatformContainer, // AndroidContainer | IosContainer | DesktopContainer) { val userRepository: UserRepository by lazy { UserRepository(platformContainer.userCredentialsStore) }}
Выделяем любую такую ось, выносим её в отдельный фрагмент контейнера и подставляем нужный фрагмент при сборке. Осей может быть несколько.
-
Флейвор сборки. Free и paid различаются набором зависимостей. Общая часть графа живёт в главном контейнере, а различающаяся — в
FreeContainerиPaidContainer; точка сборки подставляет нужный фрагмент под текущий флейвор. -
Вариант UI. Старый и новый интерфейс тянут разные презентационные зависимости. Они выносятся в
OldUiContainerиNewUiContainer, и в графе оказывается ровно тот, что включён сборкой.
// главный контейнер собирается из фрагмента-осиclass AppContainer( private val flavor: FlavorContainer, // FreeContainer | PaidContainer) { val featureGate: FeatureGate by lazy { FeatureGate(flavor.entitlements) }}
Также здесь проявляется главный плюс — наглядность. В случае, если нужно и можно вынести отдельную ось — скорее всего это кандидат для отдельного модуля или самостоятельной библиотеки. Ручной DI только подсвечивает возможность, но позволяет оставить ситуацию как есть.
Тестируемость
Контейнер — это ещё и шов, через который тесты подставляют фейки. Чтобы это работало, поля контейнера объявляются как open val или abstract val: переопределение и есть механизм подмены. Никаких сеттеров, никакого lateinit var.
class FakeContainer : AppContainer() { override val httpClient = FakeHttpClient() override val messageRepository = FakeMessageRepository()}
Тест собирает фейковый контейнер наследованием и переопределяет только нужные поля — не плодя класс под каждый сценарий. Компоненты, которые берут зависимости в конструктор, тестируются напрямую: контейнер им не нужен вовсе, он существует ради боевой сборки.
Для тестов Android‑компонентов под Robolectric нужен Application, который реализует интерфейс‑провайдер. Либо берётся боевой Application, либо определяется тестовый с фейковым контейнером — и каждый тест получает свежий экземпляр, ничего не оседает в статике.
Границы подхода
Честная оговорка про то, чего ручной DI не даёт.
Это свод принципов, а не механизм принуждения
Ничто в нём не мешает создать зависимость прямо в классе или спрятать состояние в object. Ровно так же ни один фреймворк не запрещает писать плохо. Разница в том, что нарушение здесь видно глазами: лишний вызов конструктора или обращение к глобалу торчат в коде и в диффе. Нужны гарантии — это уровень кастомных линтеров; без них остаётся инженерная культура команды.
Проверка связности графа
Забытую зависимость компилятор и так ловит: конструктор без аргумента не вызвать, поэтому «забыл предоставить X» просто не скомпилируется. Единственная брешь — циклические зависимости через ленивые поля (by lazy): когда два компонента ссылаются друг на друга, цикл всплывает в рантайме при первом обращении, а граф‑валидирующий фреймворк ловит его на сборке. Брешь узкая и одна; к тому же всплывший цикл — это пойманная реальная проблема дизайна, а не спрятанная, так что чистым проигрышем это назвать сложно.
Скоупы
У ручного DI нет встроенного механизма скоупов. Все контейнеры — синглтоны на весь процесс: нет дочернего графа на экран, нет зависимости, привязанной к жизненному циклу, нет выгрузки части графа из памяти, когда она больше не нужна. Именно это дают зрелые фреймворки.
Объектами, живущими ровно пока открыт экран, владеет слой навигации или экрана — обычным Kotlin: они приходят в конструктор сверху или создаются на месте, а отпускаются вместе с владельцем, когда тот закрывается. Этого хватает, пока владельцев жизненного цикла немного и они выражены явными классами. Когда нужны DI‑управляемые скоупы, lifecycle‑bound зависимости или выгружаемые фичи — ответ даёт фреймворк (см. «Миграция на фреймворк»).
Контейнер держит синглтоны на весь процесс, и Closeable‑компоненты вроде HttpClient закрываются вместе с процессом или Application — отдельной выгрузки графа из памяти у ручного DI нет. Где компонент живёт короче процесса, действует тот же принцип владения: close() вызывает тот, кто владеет его жизненным циклом, — это ручная работа, автоматического освобождения здесь не происходит.
Как это масштабируется на библиотеки и модули
Пока приложение — один модуль, composition root остаётся плоским. Но тот же подход растёт до отдельных KMP‑библиотек и до композиции модулей внутри приложения, и там добавляется ещё один разрез: что модуль или библиотека показывают наружу, а что прячут.
Разделение на Api и Impl
Библиотека или модуль делится надвое. Модуль Api содержит только интерфейсы и модели для внешнего использования. Модуль Impl зависит от Api и содержит все реализации.
Это разделение может выглядеть притянутым, но на самом деле оно — продолжение того же принципа composition root, только на уровне модулей. До сих пор мы обсуждали единственную точку создания компонентов. Разделение на Api и Impl поднимает тот же принцип на ступень выше: реализацию видит только одно место — модуль, который собирает DI. Все остальные модули компилируются против Api и про существование Impl не знают.
Ценность здесь — контроль над тем, кто вообще имеет право зависеть от реализации. Раз на Impl смотрит только DI‑модуль (модуль точки сборки), какой объект стоит за интерфейсом Api — решает точка сборки, и потребители этого не касаются.
Показательный случай — опциональная или подгружаемая фича. Решение «доступна ли реализация и какую версию поднять» принимается ровно один раз, в composition root. Недоступна — корень заводит за тем же интерфейсом Api заглушку или обрабатывает случай иначе. Доступна — резолвит и подставляет настоящий Impl. А как она доставлена — Android dynamic‑feature, отдельная загрузка из удалённого хранилища, выбор нужного .so под рантайм‑условия — остальному коду неважно: потребители держат в руках только Api и вызывают функциональность напрямую, без единой проверки «загружено ли это».
Публичный и внутренний контейнеры
У библиотеки контейнер раздваивается по видимости.
-
LibPublicContainer — публичное API (фасад) библиотеки. Объявлен в Api‑модуле. В нём лежат все зависимости, которые библиотека отдаёт внешним потребителям.
-
LibInternalContainer — наследник публичного, объявлен в Impl‑модуле. Добавляет зависимости для внутреннего использования. Внешним потребителям его тип не виден, поэтому опереться на него снаружи библиотеки физически нельзя.
// модуль Apiinterface LibPublicContainer { val someRepository: SomeRepository}// модуль Implinterface LibInternalContainer : LibPublicContainer { val someInternalService: SomeInternalService}
Точка входа в библиотеку
Контейнер кто‑то должен собрать — выстроить платформенную часть, выдержать порядок сборки. Эта сборка живёт в модуле Impl, за границей Api, и наружу торчит одной фабричной функцией:
// модуль Impl — виден только DI-модулю приложенияfun createLibContainer(config: LibConfigContainer): LibInternalContainer = LibContainerImpl(config)
Функция собирает реализацию внутреннего контейнера, который содержит все внутренние классы библиотеки, и каждый из них уже получил свои зависимости в конструктор при создании. DI‑модуль приложения — единственный, кто видит Impl, поэтому держит контейнер по внутреннему типу, а наружу отдаёт публичный фасад:
class App { val libContainer: LibPublicContainer by lazy { createLibContainer(AndroidLibConfig(application = this)) }}
Честная оговорка: createLibContainer живёт в Impl‑модуле, поэтому DI‑модуль приложения берёт Impl в зависимости. Если в этом модуле нарушить уговор «внутренний контейнер — только для инициализации, а пользоваться библиотекой — по публичному», спроектированные границы потекут. На чистом Kotlin без рефлексии от этого не защититься: остаются кастомные линтеры, обфускация (где применима) или дисциплина команды.
Дальше готовый контейнер достаточно прокинуть в конструкторы внешних потребителей:
val someInteractor: SomeInteractor by lazy { SomeInteractor(repository = libContainer.someRepository)}
Статический доступ изнутри библиотеки
Иногда внутренним классам библиотеки нужно достать контейнер статически — та же история, что с Android‑компонентами, и та же оговорка: это локальный service locator на границе с фреймворком. Чтобы он не протёк наружу, провайдер типизируется ровно на то, что нужно вызывающему. Снаружи доступен LibProvider с публичным типом — он объявлен в Api. Внутреннему коду нужен внутренний контейнер, поэтому для него — отдельный LibInternalProvider с внутренним типом, объявленный в Impl и наружу не видимый.
// модуль Api: публичный провайдерinterface LibProvider { val lib: LibPublicContainer}// модуль Impl: провайдер для внутреннего контейнера.// Наружу не виден не из-за модификатора, а потому что Impl нет на classpath потребителей.interface LibInternalProvider { val internalLib: LibInternalContainer}// в приложении один и тот же App отдаёт оба лица контейнераclass App : Application(), LibProvider, LibInternalProvider { private val container: LibInternalContainer by lazy { createLibContainer(AndroidLibConfig(application = this)) } override val lib: LibPublicContainer get() = container // наружу — публичный фасад override val internalLib: LibInternalContainer get() = container // внутрь — расширенный}// внутри библиотеки — один каст, и при неверной сборке он падает с понятной ошибкойclass LibActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val provider = application as? LibInternalProvider ?: error("Application must implement LibInternalProvider") provider.internalLib.someInternalService.start() }}
Отдельный внутренний провайдер избавляет от двойного каста as? … as?, в котором два разных промаха слились бы в одну молчаливую ветку и внутренний start() просто не случился бы. Здесь каст один и при неверной сборке падает с IllegalStateException, а не тихо отключает фичу.
Порядок взаимодействия с библиотекой:
-
Приложение инициализирует библиотеку через ее фабрику
-
Библиотека возвращает приложению свой контейнер на хранение
-
Внутренний Android компонент библиотеки получает внутренний контейнер у приложения как LibInternalProvider
-
Android компонент приложения получает контейнер приложения у приложения как AppContainerProvider
-
Android компонент приложения получает публичный контейнер библиотеки у приложения как LibPublicProvider
Миграция на фреймворк
Структурный триггер добавления фреймворка по сути один — скоупы и lifecycle‑bound зависимости. Когда графу нужно ограничение по времени жизни и явная выгрузка из памяти.
Не триггер, но дополнительная стоимость: во многих фреймворках реализованы методы для упрощения получения ViewModel. При использовании MVVM и ручного подхода, нужно будет вручную прописать фабрики моделей и использовать стандартный ViewModelStore.
Переход при этом дёшев: контейнеры и провайдеры по форме уже близки к графу фреймворка — контейнер становится графом зависимостей, платформенные фрагменты и конфигурация — вкладами в него, тесты на open val — графами с переопределённым связыванием. Какой именно фреймворк брать — отдельный разговор; для KMP смотрят на поддержку всех таргетов, проверку графа на компиляции и эргономику в многомодульном проекте, можно рассмотреть Metro или kotlin‑inject.
Практический разбор такой миграции на Metro в KMP‑мобильном приложении — Metro DI for KMP Mobile FunkyMuse: стартовая точка там была не composition root, а самописный God Object, но итоговая форма (скоупы, multibindings, contributes‑to) — ровно то, что разблокирует фреймворк после описанных триггеров.
Итог
Ручной DI стоит на одном принципе: граф зависимостей собирается в единственной точке — composition root, а все остальные классы получают готовые зависимости в конструктор. Разделение на Api/Impl и композиция контейнера из частей — тот же принцип, поднятый на уровень модулей и разнесённый по осям, по которым приложение варьируется.
Платой остаётся ручная компоновка, выигрышем — наглядность: граф лежит в коде, без магии, и читается одинаково человеком, IDE и ИИ‑агентом. Пока граф помещается в голову, это дёшево; когда перестанет — контейнеры и провайдеры уже близки по форме к графу фреймворка, и переход не потребует структурной перетряски.
Связанные материалы
Идея не нова: контейнер‑фасад для KMM с делением на публичную и внутреннюю часть ещё в 2023-м описывал Марцин Пекельны. Здесь добавлено то, что обеспечивает реальную применимость: иерархия контейнеров по сорс‑сетам на всех платформах, паттерн провайдера для Android, композиция по осям и масштабирование на модули — Api/Impl, public/internal.
Код, на котором основана статья, открыт: github.com/khmelevartem/tether. Полный разбор паттерна и чек‑лист для нового кода живут в docs/engineering/dependency-injection.md.
ссылка на оригинал статьи https://habr.com/ru/articles/1051804/