Java-проекту девять лет, в нём двести тысяч строк, полсотни модулей Gradle и релиз каждые две недели. На планёрке рождается план перевести всё на Kotlin за пару спринтов: команда сильная, тесты есть, чего тянуть. Бодрость испаряется на первом же диффе из тридцати тысяч строк: такое ревью физически не провести, откатить мегаветку нельзя, а продукт всё это время стоит. Хорошая новость в том, что спешка и не нужна. Kotlin компилируется в тот же байткод, живёт с Java в одном jar, собирается тем же Gradle и внутри одного процесса, поэтому язык переезжает файл за файлом, не останавливая ни разработку, ни релизы. Ниже механика смешанной сборки, ловушки на границе двух языков и порядок, при котором проект становится Kotlin-first за квартал без единого дня заморозки.

Переписывание всего Java-проекта за один спринт ломается даже у сильной команды

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

Дальше вступает планирование. Мегаветка живёт две-три недели, в основную линию тем временем вливают новые функции, и merge-конфликты растут снежным комом. Откатить переписывание точечно нельзя, заморозка фич на время работ стоит дороже самих работ. Наконец, люди: Kotlin идиоматически отличается от Java, и код после автоконвертера звучит как Java, переписанный другими буквами. Команда без опыта пишет на новом языке медленнее и честно транслирует в него старые привычки.

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

У полного переписывания есть один честный плюс: результат однороден, швов между языками нет. Но смешанный проект давно не переходный стыд, а поддерживаемый режим, и однородность ради однородности не стоит квартала простоя.

Как Gradle собирает смешанный проект и в каком порядке работают компиляторы

Kotlin подключается к существующей сборке парой строк. У Kotlin Gradle плагина нумерация версий совпадает с самим языком: сегодня это ветка 2.4, а для релизов плагина 2.4.20 и 2.4.21 минимально поддерживаемый Gradle начинается с версии 7.6.3. Исходники Kotlin по умолчанию кладутся в src/main/kotlin, Java остаётся в src/main/java, но каталоги можно свести в один.

plugins {
    java
    kotlin("jvm") version "2.4.21"
}

kotlin {
    jvmToolchain(21)
}

Порядок сборки в смешанном модуле всегда одинаков: задача compileKotlin стартует раньше compileJava, компилятор Kotlin разбирает Java-исходники для анализа типов, затем javac собирает Java уже с байткодом Kotlin на classpath. Ручных заглушек не требуется, оба языка видят друг друга без посредников. Слабое место схемы одно: циклические ссылки между Kotlin и Java внутри одного модуля, они требуют ручных правок, поэтому при миграции новые Kotlin-файлы сначала зависят от Java, а не наоборот.

Отдельная группа ошибок связана с версиями байткода. Если jvmTarget у Kotlin и targetCompatibility у Java разойдутся, плагин сообщит о несовпадении прямо на сборке. Надёжнее не задавать версии вручную: toolchain выдаёт обеим сторонам одну и ту же версию JDK автоматически.

Если toolchain не подходит, цели задают руками и держат синхронно:

\\\`
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
\\\`

Проекты на Maven тоже не остаются в стороне: у JetBrains есть kotlin-maven-plugin, который встраивается в фазы compile и test-compile и собирает те же смешанные модули. Привязки к одной системе сборки нет, миграция не зависит от выбора между Gradle и Maven.

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

Что Kotlin видит в Java-коде через platform types и null-безопасность границы

Типы Kotlin различают ссылки, которые могут содержать null, и ссылки, которым null запрещён. Java такого различия не знает, поэтому результат любого вызова Java-кода получает статус platform type: компилятор помечает его восклицательным знаком в подсказках и отключает проверку.

val name: String = javaDao.findName() // собирается без ошибок
println(name.length)                  // NullPointerException в рантайме

Код выглядит безопасно, типы зелёные, а падение случается на работающем сервере в три часа ночи. Границу лечат аннотациями: @Nullable и @NonNull у Java-методов заставляют Kotlin честно выводить String? вместо String. Для старых модулей без разметки включают обработку JSR 305, у которой три режима: игнорировать, предупреждать и строгий, где отсутствие аннотаций становится ошибкой компиляции. Современные Java-модули размечают границы наборами JSpecify, компилятор Kotlin понимает и их.

fun requireName(dao: JavaDao): String =
    dao.findName() ?: throw IllegalStateException("имя не найдено")

Практическое правило миграции: вызовы Java оборачивать тонкими адаптерами на Kotlin, которые проверяют null один раз и дальше раздают честные типы. Такой пограничный слой экономит недели отладки и превращает рассуждения о null-безопасности в код, а не в устную договорённость.

Адаптеры границы покрывают тестами отдельно: проверяется ровно одно, что Java-метод вернул null и адаптер это не пропустил. Дальше весь Kotlin-код живёт с честными типами.

Какие аннотации Jvm удерживают границу между языками ровной

Kotlin генерирует обычные классы JVM, но часть его конструкций видна из Java непривычно: функции верхнего уровня живут в классе по имени файла, параметры по умолчанию не отражаются в сигнатуре, статические члены требуют object. Для таких мест язык держит набор аннотаций.

class OrderService {
    @JvmOverloads // Java увидит два перегруженных метода
    fun find(orderId: Long, useCache: Boolean = true): Order

    companion object {
        @JvmStatic // вызов OrderService.defaultTimeouts() без объекта
        fun defaultTimeouts(): Timeouts
    }
}

@file:JvmName("OrderMapper") // читаемое имя вместо OrderMapperKt

Аннотация @JvmField открывает поле без геттера, @JvmSuppressWildcards чинит дженерики там, где Java не видит List<Payment> за List с wildcard. Самая коварная ловушка ждёт пользователей Spring: классы Kotlin по умолчанию final, а техника CGLIB для @Transactional или @Cacheable требует наследования. Один плагин kotlin-spring из семейства allopen открывает помеченные классы автоматически, без него декларативные транзакции иногда просто не включаются, и дефект находят по логам уже в продакшене.

Для горячего кода у языка есть value class с аннотацией @JvmInline: обёртка упаковывается в примитив, и лишних объектов на JVM не рождается. Идентификаторы, денежные суммы и единицы измерения принято оборачивать в типы ради типобезопасности, и начиная с версии 1.5 Kotlin делает это без накладных расходов.

Конвертер IntelliJ как отправная точка переработки а не финишная прямая

Среда разработки переводит Java-файл в Kotlin одним действием: static превращается в companion object, связки из геттера и сеттера схлопываются в свойство, System.out.println ужимается до println. Результат компилируется, и это создаёт иллюзию готовности. Автоконвертер дословен: он переписывает текст, а не замысел, и аккуратно сохраняет всё, что Java-программист писал десять лет.

// было
public class Money {
    private final long cents;
    public Money(long cents) { this.cents = cents; }
    public long getCents() { return cents; }
}

// стало
data class Money(val cents: Long)

Второй проход делает человек: классы-сумки для значений превращаются в data class, строковые коды статусов в sealed-иерархии, цепочки if в when, локальная изменяемость заменяется val и immutable-коллекциями, утилиты переезжают из static-классов в extension functions. Контроль статики держат detekt и ktlint: первый ловит запахи кода, второй форматирование, оба вешаются в CI и не зависят от вкуса конкретного ревьюера.

\\\`
./gradlew detekt
./gradlew ktlintFormat
\\\`

detekt ведёт порог технического долга и блокирует слияние при его превышении, ktlint форматирует по официальному стилю и правит файлы сам. Идиоматичность перестаёт быть вопросом вкуса: она проверяется задачей сборки.

Отдельная глава про корутины. Библиотека kotlinx.coroutines вышла в версии 1.0 в октябре 2018 года, Spring Framework получил официальную поддержку Kotlin в версии 5.0, корутины в реактивном стеке появились в 5.2. Выгода понятна: suspend вместо блокировки потоков. Проблема в том, что корутины ломают сигнатуры и требуют понимания structured concurrency. Вводить их в код, который только что прошёл автоконвертацию, значит смешать две миграции в одной ветке. Сначала язык, потом асинхронность.

Рабочий порядок миграции одного модуля без остановки разработки

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

  1. зафиксировать точку отсчёта: зелёные тесты, время полной сборки, число предупреждений компилятора, покрытие критичных веток;
  2. подключить плагин kotlin("jvm") и toolchain, прогнать сборку на чистой машине и в конвейере CI;
  3. все новые файлы писать только на Kotlin, старый код не трогать без задачи в трекере;
  4. автоконвертером перевести тесты и утилитарные классы без бизнес-логики, дифф просмотреть глазами до слияния;
  5. двигаться от листьев к корню: модели, DTO, утилиты, затем DAO, затем сервисы и контроллеры;
  6. держать каждый шаг в отдельной ветке с зелёной сборкой, чтобы откат стоил один revert, а не неделю;
  7. привести границу языков в порядок аннотациями Jvm и nullable-разметкой до того, как Java-коллеги привыкнут к обходным путям.

Типичная точка срыва встречается на перевале моделей: автоконвертер услужливо превращает сущность в data class, а у того равенство строится по всем полям сразу, включая массивы, и тесты краснеют пачками. Лечится возвращением обычного класса с явным equals или ключевым полем. Это и есть настоящая цена автоконвертера, и платить её лучше в маленьком PR на один файл, чем в мегаветке на весь проект.

Когда постепенная миграция выигрывает и в каких случаях оправдано переписывание с нуля

Смешанный проект поддерживается всеми инструментами сразу: одна JVM, один jar, один конвейер, одни зависимости. Google объявил поддержку Kotlin для Android на конференции разработчиков в 2017 году, а в 2019 закрепил за ним статус первого языка платформы; кодовую базу никто не переписывал одним махом: новый язык вливался в старый, пока соотношение не перевернулось. Kotlin 2.0 в мае 2024 года принёс компилятор K2 с новой архитектурой фронтенда: сборка ускорилась, диагностика стала точнее. Сегодня язык живёт в ветке 2.4 с общим ритмом обновлений языка и Gradle-плагина.

Прогресс измеряют числами, а не ощущениями. Доля Kotlin-файлов по модулям видна из отчёта о структуре проекта, время сборки сравнивают по метрикам конвейера до и после перевода модуля, количество NullPointerException в логах после появления адаптеров границы должно падать. Если числа не двигаются, план останавливают и пересматривают, а не тянут молча.

Переписывание с нуля оправдано в трёх случаях. Первый: сервис маленький и покрыт тестами от входа до выхода, полный перевод занимает считанные дни. Второй: меняется не только язык, но и каркас, например Spring уезжает на Ktor, и два переезда выгоднее делать одной волной. Третий: продукт молод, пользователей мало, заморозка стоит дёшево. Во всех остальных ситуациях спринт тотального переписывания это ставка, где проигрыш необратим, а выигрыш не больше, чем у квартала аккуратной миграции.

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