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. Вводить их в код, который только что прошёл автоконвертацию, значит смешать две миграции в одной ветке. Сначала язык, потом асинхронность.
Рабочий порядок миграции одного модуля без остановки разработки
Порядок, который обычно выдерживают команды из двадцати человек на проекте из десятков модулей, выглядит так:
- зафиксировать точку отсчёта: зелёные тесты, время полной сборки, число предупреждений компилятора, покрытие критичных веток;
- подключить плагин kotlin("jvm") и toolchain, прогнать сборку на чистой машине и в конвейере CI;
- все новые файлы писать только на Kotlin, старый код не трогать без задачи в трекере;
- автоконвертером перевести тесты и утилитарные классы без бизнес-логики, дифф просмотреть глазами до слияния;
- двигаться от листьев к корню: модели, DTO, утилиты, затем DAO, затем сервисы и контроллеры;
- держать каждый шаг в отдельной ветке с зелёной сборкой, чтобы откат стоил один revert, а не неделю;
- привести границу языков в порядок аннотациями 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 одним вечером: он умеет въезжать по одному файлу, и именно эта вежливость делает его реальной заменой, а не планом, который застрял в мегаветке.