expect/actual против интерфейсов и DI

Два способа протащить платформенную деталь в общий код — и как выбрать правильный.

Инверсия зависимостей в KMP — приём, при котором общий код зависит от интерфейса, а платформенная реализация передаётся ему извне через конструктор или DI-контейнер.

Две техники для одной задачи

И expect/actual, и «интерфейс + DI» решают одну проблему: дать общему коду доступ к платформенному поведению. Разница — в связывании. expect/actual связывается компилятором статически: одна expect-функция — ровно одна actual на платформу. Интерфейс связывается в рантайме: вы можете подставить любую реализацию, включая моки в тестах.

Когда уместен expect/actual

Берите его для мелких, единичных, не подменяемых вещей: имя платформы, текущее время, генерация UUID, путь к директории кэша. Здесь не нужна подмена реализации, и expect-функция лаконичнее, чем заводить интерфейс, реализацию и DI-проводку.

// commonMain
expect fun currentTimeMillis(): Long
expect fun randomUuid(): String

Когда уместен интерфейс + DI

Берите интерфейс, когда: реализация большая (база данных, сетевой клиент), нужна подмена в тестах, или у зависимости есть собственная конфигурация. Интерфейс позволяет в тесте передать фейк без всякого Kotlin/Native.

// commonMain
interface AnalyticsTracker {
    fun track(event: String, params: Map<String, String>)
}

class OrderViewModel(private val analytics: AnalyticsTracker) {
    fun onPay() {
        analytics.track("pay_clicked", mapOf("screen" to "cart"))
    }
}

В тесте подставляем FakeAnalyticsTracker, в проде — платформенную реализацию.

Как работает под капотом

Тестируемость — главный аргумент за интерфейсы. expect/actual жёстко прибивает реализацию к платформе на этапе компиляции; чтобы протестировать общий код, использующий expect, вам нужна actual в тестовом наборе — это возможно, но громоздко. Интерфейс же подменяется одной строкой. Поэтому зрелые KMP-проекты используют expect/actual точечно (системные мелочи) и строят основную архитектуру на интерфейсах с DI.

Частые ошибки

Сделать всё на expect/actual и упереться в нетестируемость общего кода. Или наоборот — завести интерфейс ради одной системной константы, раздув проект. Правило: подменяемое и крупное — интерфейс; единичное и системное — expect/actual.

Итоги

  • expect/actual — статическое связывание; интерфейс + DI — рантайм-связывание.
  • expect/actual хорош для мелких системных вещей без подмены.
  • Интерфейс нужен для крупных зависимостей и тестируемости.
  • Зрелые проекты комбинируют оба подхода осознанно.
Проверьте себя
1. Какой главный аргумент в пользу интерфейса вместо expect/actual?
AМеньше кода всегда
BТестируемость: реализацию легко подменить моком
CБыстрее компиляция
DРаботает только на Android
2. Для чего лучше всего подходит expect/actual?
AДля крупной базы данных с конфигурацией
BДля сетевого клиента
CДля мелких системных вещей вроде текущего времени и UUID
DДля UI-экранов