В вакансиях backend-разработчика всё чаще встречается пара «Java/Kotlin» — не как два разных стека, а как один. Причина простая: Kotlin компилируется в тот же байткод JVM, использует те же библиотеки и фреймворки, и команда может писать новый код на Kotlin, не переписывая старый Java-код. Разберёмся, где язык живёт, что даёт и сколько стоит переход.
Эта статья открывает раздел, и в маршруте подготовки он стоит поздно — после Java, Spring, конкурентности, хранилищ и архитектурных разделов. Порядок не случайный: Kotlin не добавляет новой платформы, он меняет способ писать на уже знакомой. Про корутины нечего сказать тому, кто не разбирался с потоками и пулом; про data class и неизменяемость — тому, кто не писал носители данных руками; про nullability на уровне типов — тому, кто не ловил NullPointerException в проде. Поэтому раздел читается не как «второй язык с нуля», а как перевод уже понятных решений на другой синтаксис: каждая статья опирается на то, что соответствующая тема на Java уже разобрана.
Где живёт Kotlin
Android. В мае 2019 года на Google I/O объявили подход, который назвали Kotlin-first: новые библиотеки, примеры и документация Android делаются в первую очередь под Kotlin, а Java остаётся поддержанной, но догоняющей. Если встретите мобильного разработчика — почти наверняка он пишет на Kotlin.
Backend на JVM. Spring Boot официально поддерживает Kotlin: документация Spring даёт примеры на двух языках, стартеры генерируются с Kotlin-конфигурацией из коробки. Ktor и http4k — фреймворки, написанные на Kotlin изначально. Серверный код на Kotlin пишут и в самой JetBrains, и в банках, и в продуктовых командах; насколько велика эта доля в отрасли, публично никто не мерил — ориентируйтесь на вакансии своего рынка, а не на общие слова.
Инструменты сборки. Gradle-скрипты на Kotlin (build.gradle.kts) вытесняют Groovy-вариант: та же логика, но с автодополнением и проверкой типов в IDE.
Что даёт язык на практике
NPE в проде из-за поля, которое «никогда не бывает null». В Kotlin String и String? — разные типы: компилятор не даст вызвать метод у значения, которое может быть null. Класс ошибок, который в Java ловят в проде, в Kotlin не компилируется. Подробно — в статье про null-safety.
Сто строк ради класса из двух полей. Носитель данных с equals, hashCode, toString и copy в Kotlin — одна строка:
data class Money(val amount: BigDecimal, val currency: String)
В Java это либо record (с Java 16), либо Lombok, либо сотня строк руками.
Функция там, где она нужна, а не в утилитном классе. Функцию можно объявить прямо у чужого типа (extension-функция), передать в другую функцию без интерфейса-обёртки, а коллекции обрабатывать через map и filter без stream() и collect() — код читается ближе к намерению.
Тысяча одновременных запросов к внешнему API без тысячи системных потоков. Это корутины: функция может приостановиться на время ожидания и отдать поток другим, поэтому «потоков» столько, сколько нужно задаче, а не столько, сколько выдержит система. Как это соотносится с virtual threads из Java 21 — отдельный разговор.
Совместимость: главный аргумент
Kotlin и Java живут в одном проекте без прослоек:
- из Kotlin Java-код виден как есть: тот же класс, те же методы, никаких обёрток;
- один Gradle-модуль может содержать и
src/main/java, иsrc/main/kotlin; - вся экосистема Java доступна как есть: Spring, Hibernate, Jackson, JUnit, любая библиотека из Maven Central.
А вот обратное направление — Java зовёт Kotlin — гладкое не до конца, и об этом лучше знать заранее. Функции верхнего уровня из файла Slugs.kt Java видит как статические методы класса SlugsKt. Метод из companion object — как Order.Companion.create(), а не Order.create(). Значения параметров по умолчанию из Java не видны вовсе — там придётся передать всё. Лечится это аннотациями на стороне Kotlin: @JvmStatic поднимает метод companion-объекта до настоящего статического, @JvmOverloads дописывает лестницу перегрузок под значения по умолчанию, @JvmName задаёт имя, под которым конструкция видна из Java. Их ставят там, где Kotlin-код действительно зовут из Java, — то есть далеко не везде.
Ещё одно исключение стоит знать заранее, потому что на него натыкаются в первом же смешанном модуле: Lombok. Kotlin-код в одном модуле с ним не видит сгенерированных геттеров и билдера, и сборка падает на «unresolved reference». Причина и обходы разобраны ниже, в разделе «Глубже: Kotlin и Lombok в одном модуле».
Поэтому переход не требует «большого переписывания»: новый сервис или новый пакет пишут на Kotlin, старое остаётся на Java. Границу проводят по модулю или пакету, а не по всему репозиторию.
Два каталога исходников и два компилятора сходятся в один каталог классов: дальше всё равно один байткод и одна машина.
Смешанный модуль: как это выглядит в сборке
«Один модуль может содержать и то и другое» — фраза, которая ничего не объясняет, пока не увидишь раскладку файлов и три строки в сборке.
Каталоги лежат рядом, по одному на язык:
service/
build.gradle.kts
src/
main/
java/ru/example/order/OrderEntity.java
kotlin/ru/example/order/OrderService.kt
resources/application.yml
test/
kotlin/ru/example/order/OrderServiceTest.kt
В сборке подключается плагин языка, и этого достаточно:
plugins {
java
kotlin("jvm") version "2.0.21"
kotlin("plugin.spring") version "2.0.21"
}
dependencies {
implementation(kotlin("stdlib"))
implementation("org.jetbrains.kotlin:kotlin-reflect")
}
kotlin {
jvmToolchain(21)
}
Что делает kotlin("jvm"): заводит задачи компиляции Kotlin, добавляет src/main/kotlin и src/test/kotlin в исходники (каталоги java при этом никуда не деваются — компилятор Kotlin умеет читать и их), подтягивает стандартную библиотеку. kotlin("plugin.spring") нужен отдельно: он снимает final с классов, помеченных аннотациями Spring, — без этого прокси Spring не создаются, потому что в Kotlin классы по умолчанию закрыты для наследования.
Дальше важная деталь, из-за которой смешанный модуль вообще работает. Оба компилятора кладут результат в один каталог классов, и порядок такой: сначала компилируется Kotlin (он при этом читает исходники Java, чтобы видеть их типы), потом компилируется Java (она видит уже готовый байткод Kotlin). Отсюда два следствия. Kotlin видит Java-классы как есть — но только то, что написано в исходниках, а не то, что появится в них при генерации: ровно по этой причине Lombok и ломается, о чём раздел ниже. А Java видит Kotlin-классы полностью, включая то, что в них сгенерировал компилятор.
Размещение по каталогам при этом не догма: Kotlin-файл можно положить и в src/main/java — компилятор его найдёт. Так иногда делают при постепенном переводе пакета, чтобы не разносить один пакет по двум деревьям. Раскладку по разным каталогам предпочитают потому, что по ней сразу видно, сколько в модуле какого языка.
С чего начать: поставить и запустить
Практический вопрос, который возникает раньше всех остальных, — что вообще нужно установить.
Короткий ответ для тех, у кого есть IntelliJ IDEA. Ничего. Поддержка Kotlin встроена в IDE (в том числе в бесплатную Community-редакцию): создайте файл с расширением .kt, напишите функцию main, нажмите запуск. Компилятор придёт вместе с проектом, отдельная установка не нужна.
Первый файл без проекта. Самый быстрый способ попробовать язык — не устанавливать ничего вообще: на сайте языка есть онлайн-площадка, где код запускается в браузере. Для локального запуска понадобится компилятор командной строки — его ставят менеджером пакетов операционной системы или скачивают архивом с релизной страницы проекта. Дальше это выглядит привычно: компилятор собирает файл в .jar, который запускается обычной java -jar. Есть и режим одной команды — компилятор умеет выполнить файл как скрипт, без промежуточной сборки.
Минимальный файл, который стоит набрать первым:
fun main() {
val greeting = "Kotlin на той же JVM"
println(greeting)
println(listOf(3, 1, 2).sorted().joinToString(" < "))
}
Здесь уже видны три вещи, которых нет в Java: функция вне класса, val вместо типа слева и вывод коллекции без цикла.
Первый проект на Spring Boot. Генератор start.spring.io умеет Kotlin из коробки: в поле языка выбирается Kotlin, тип сборки — Gradle с Kotlin DSL. В сгенерированном проекте окажется ровно то, что нужно, и это готовый список того, что пришлось бы прописывать руками: плагины kotlin("jvm"), kotlin("plugin.spring"), зависимость kotlin-reflect (её требует Spring для работы с конструкторами Kotlin), модуль Jackson для Kotlin (без него не десериализуются классы с конструкторными параметрами), включённые строгие проверки для типов с платформы Java и главный класс в виде функции верхнего уровня.
Что делать в живом Java-проекте. Порядок такой: добавить плагин Kotlin в сборку, решить вопрос с Lombok (плагин компилятора или отдельный модуль — см. ниже), завести src/main/kotlin, перевести или написать заново один небольшой класс, прогнать тесты. Первым переводят не то, что важнее, а то, что проще проверить: утилиту без состояния, носитель данных, тест. Конвертер Java-кода в Kotlin в IDE существует и полезен как черновик, но его результат — это «Java с другим синтаксисом», то есть ровно та ловушка, о которой раздел ниже.
Сколько стоит переход
Java-разработчику Kotlin даётся быстро: модель JVM, память, потоки, сборка, фреймворки — всё уже знакомо. Новое — синтаксис и несколько идиом. Дальше — грубые ориентиры из опыта переходивших, а не измеренные цифры: у вас легко выйдет вдвое быстрее или вдвое медленнее.
- неделя — читать чужой Kotlin-код свободно;
- две-четыре недели — писать рабочий код без подглядывания;
- пара месяцев — идиоматичный Kotlin вместо «Java с другим синтаксисом».
Последний пункт — самая частая ловушка: код на Kotlin, написанный «как Java», компилируется и работает, но теряет главные преимущества языка. Этот раздел — как раз про идиомы, а не только про синтаксис.
Чем платят за второй язык
Раздел выше перечисляет выигрыш. Обратная сторона есть, и о ней лучше знать до решения, а не после.
Сборка идёт медленнее. Компилятор Kotlin делает больше работы, чем javac, и на крупном модуле это заметно: в смешанном модуле компиляция идёт двумя проходами, и общее время сборки вырастает. На маленьком сервисе разница незаметна, на модуле в сотни тысяч строк она превращается в минуты и бьёт по скорости обратной связи. Лечится частично: инкрементальная компиляция, демон Gradle, кэш сборки, вынос редко меняющегося кода в отдельные модули.
Инструментарий вне одной IDE беднее. Kotlin делает JetBrains, и в IntelliJ IDEA поддержка образцовая. В других редакторах и средах она есть, но отстаёт. То же касается сторонних инструментов: анализаторы кода, средства измерения покрытия, генераторы отчётов, плагины сборки — многие из них про Java знают всё, а про Kotlin часть. Отдельный сюжет — обработчики аннотаций: их приходится подключать через специальный механизм (kapt, теперь — ksp), и он работает медленнее и настраивается сложнее.
Найм под Kotlin уже. Java-разработчиков на рынке существенно больше. Практически это решается тем, что Java-разработчика берут с расчётом «выучит за месяц» — и это работает, но в план входит. Обратная сторона того же: команда, где Kotlin знают два человека из восьми, получает код, который семеро читают медленно.
Стектрейс и байткод читать труднее. Здесь три разных неприятности. Функции верхнего уровня появляются в стектрейсе под именем класса-файла (ParsersKt), и это не то имя, которое написано в коде. Приостанавливаемые функции корутин рвут стектрейс: механика приостановки устроена так, что цепочка вызовов не сохраняется целиком, и без специального режима отладки вместо пути к ошибке видно обрубок. Сгенерированного кода вообще больше: проверки на null на границах публичных функций, синтетические методы для параметров по умолчанию, лямбды, ставшие классами. При обычной отладке это не мешает, а при разборе странного поведения в проде приходится помнить, что байткод не соответствует исходнику один в один.
Сам язык больше. У Kotlin богатый синтаксис, и это обоюдоострое свойство: та же задача решается пятью способами, и команда без договорённостей о стиле получает пять стилей. Поэтому в Kotlin-проектах почти сразу появляются средства проверки форматирования и статического анализа — не от занудства, а потому что без них разнобой копится быстрее, чем в Java.
Связка версий. Плагин Kotlin, версия языка, версия стандартной библиотеки, версии плагинов компилятора (для Spring, для сериализации, для Lombok) и версия самого Kotlin в Gradle обязаны быть согласованы. Обновление версии языка — это не строчка в файле сборки, а согласованный шаг для нескольких зависимостей.
Ни один из этих пунктов не является доводом против Kotlin. Они являются доводом против решения «переводим всё» без подсчёта: цена платится в сборке, в найме и в отладке, а выигрыш приходит в коде, который пишут и читают каждый день.
«Kotlin как Java»: та же задача в двух видах
«Писать на Kotlin как на Java» — главная ловушка перехода, и без кода эта фраза ничего не значит. Вот один класс в двух видах. Оба компилируются и работают.
Сначала так, как его напишет Java-разработчик в первую неделю:
class OrderService(private val repository: OrderRepository) {
fun findPaid(customerId: Long?): List<Order> {
if (customerId == null) {
throw IllegalArgumentException("customerId is null")
}
val all = repository.findByCustomer(customerId)
val result = ArrayList<Order>()
for (order in all) {
if (order.status == OrderStatus.PAID) {
result.add(order)
}
}
if (result.size == 0) {
return ArrayList()
}
return result
}
fun describe(order: Order?): String {
if (order == null) {
return "нет заказа"
}
val sb = StringBuilder()
sb.append("заказ ")
sb.append(order.id)
sb.append(" на ")
sb.append(order.total)
return sb.toString()
}
}
Теперь то же самое на Kotlin:
class OrderService(private val repository: OrderRepository) {
fun findPaid(customerId: Long): List<Order> =
repository.findByCustomer(customerId).filter { it.status == OrderStatus.PAID }
fun describe(order: Order?): String =
order?.let { "заказ ${it.id} на ${it.total}" } ?: "нет заказа"
}
Что именно поменялось и почему это не косметика:
| Было | Стало | Почему это важнее вида |
|---|---|---|
customerId: Long? плюс проверка и исключение | customerId: Long | проверка переехала в тип: вызвать с null теперь не даст компилятор, а не рантайм |
цикл с накоплением в ArrayList | filter { } | видно намерение, а не механику; результат неизменяемый |
| «пустой результат — вернём пустой список» | ничего | filter и так вернёт пустой список, ветка была лишней |
StringBuilder по частям | шаблон строки | компилятор всё равно соберёт строку эффективно |
if (order == null) return ... | ?.let { } ?: ... | обе ветки читаются как одно выражение |
тело в фигурных скобках с return | тело-выражение через = | тип выводится, return не нужен |
Признаки, по которым «Java с другим синтаксисом» узнаётся в чужом коде: типы с вопросительным знаком там, где значения не бывает null; !! вместо разбора, откуда пришёл null; ручные циклы вместо операций над коллекциями; изменяемые var и MutableList по умолчанию; StringBuilder; if как инструкция, а не как выражение; утилитные классы со статическими методами вместо функций верхнего уровня и расширений.
Проверка на себе занимает минуту: возьмите свой Kotlin-файл и посчитайте var, !! и ? в типах. Если их много — код скомпилируется, но выигрыша от перехода в нём нет.
Когда Kotlin не нужен
- Команда целиком на Java, кода на Kotlin нет, найм — под Java: изучение впрок даст мало.
- Java уже закрывает нужды: records, sealed classes и pattern matching сократили разрыв — см. современные возможности Java.
- Проект на другом стеке: вне JVM у Kotlin есть Kotlin Multiplatform — общий код, который собирается сразу под несколько платформ; Kotlin/Native и Kotlin/JS — её цели компиляции, а не отдельные истории. В backend-практике это встречается редко.
Kotlin — не замена Java, а второй язык той же платформы. Ценность — в широте: разработчик, читающий оба языка, работает в любом JVM-проекте.
Глубже: обратное направление: что джавист видит в скомпилированном Kotlinрасширенное
«Гладкое не до конца» выше требует конкретики, потому что в живом сервисе Java-код зовёт Kotlin-код с первого дня, и разница видна в именах.
Функции верхнего уровня живут в классе по имени файла: функция parse из Parsers.kt из Java видна как ParsersKt.parse(...). Имя класса меняют аннотацией @file:JvmName("Parsers") в начале файла. Функции companion object без аннотации вызываются как Order.Companion.create(...); @JvmStatic делает их настоящими статическими методами Order.create(...). Свойства превращаются в геттеры и сеттеры по правилам JavaBeans: val total это getTotal(), var paid это isPaid() и setPaid(), если имя начинается с is; чтобы Java видела поле напрямую, ставят @JvmField, а константы объявляют const val.
Параметры по умолчанию для Java не существуют: метод с тремя необязательными параметрами виден как один метод со всеми; @JvmOverloads генерирует перегрузки, по одной на каждый параметр по умолчанию справа налево. Исключения в Kotlin непроверяемые, и Java-компилятор не даст поймать IOException от Kotlin-метода, пока тот не помечен @Throws(IOException::class). suspend-функции из Java не вызвать: последним параметром у них скрытая Continuation, и для Java-клиентов делают обёртку, которая возвращает CompletableFuture через future { } из kotlinx-coroutines-jdk8. И Unit в Java это void, а Nothing не имеет значений вовсе.
Правило для смешанного модуля: у публичного Kotlin-API, которое зовут из Java, стоят @JvmStatic, @JvmOverloads, @JvmName там, где без них имена странные, и это проверяют одним Java-тестом, который вызывает этот API. Обратная сторона, вызов Java из Kotlin, гладкая, и о единственной её ловушке, платформенных типах, говорит статья про null-safety.
Глубже: Kotlin и Lombok в одном модулерасширенное
Сценарий, который статья рекламирует как основной, добавить Kotlin в живой Java-сервис, упирается в первый же день в Lombok: Kotlin-класс зовёт order.getTotal() у Java-класса с @Getter, и компилятор Kotlin отвечает, что такого метода нет.
Причина в порядке компиляции. В смешанном модуле сначала компилируется Kotlin, и чтобы видеть Java-классы, он читает их исходники, а не байткод; Lombok же генерирует методы во время компиляции Java, которая идёт после. Для компилятора Kotlin методов ещё нет. Java при этом Kotlin-классы видит нормально, потому что к её компиляции байткод Kotlin уже готов.
Три выхода. Плагин компилятора kotlin("plugin.lombok") учит компилятор Kotlin понимать основные аннотации Lombok (@Getter, @Setter, @Builder, @Value и другие) по конфигурации lombok.config; это штатный путь для смешанного модуля. Разделение модулей: Java-код с Lombok в одном модуле Gradle, Kotlin в другом, который от него зависит и видит уже собранный байткод; чище, но требует, чтобы граница модулей совпадала с границей языков. И замена Lombok на средства языка по мере переезда: @Value и @Data это data class, @Builder это именованные параметры со значениями по умолчанию, @Slf4j это свойство логгера в companion; это конечная точка, но не первый шаг.
Что не помогает: kapt с Lombok в качестве обработчика, потому что Lombok не обработчик аннотаций в обычном смысле, и настройки «сначала Java, потом Kotlin», которые ломают вызов Kotlin из Java. Плагин компилятора или два модуля, и выбор делают до первого Kotlin-файла в сервисе.
Коротко
- Kotlin живёт на той же JVM: те же библиотеки, тот же Spring. Из Kotlin Java-код виден как есть; обратно, из Java в Kotlin, нужны аннотации
@JvmStaticи@JvmOverloads, а Lombok в одном модуле с Kotlin требует отдельного плагина. - Главные практические плюсы: null-safety на уровне типов, data classes, extension-функции, корутины. Переход для Java-разработчика — недели: платформа знакома, новое — синтаксис и идиомы.
- Частая ошибка — писать на Kotlin «как на Java»: компилируется, но теряет смысл перехода. Java догоняет (records, sealed, virtual threads), поэтому выбор языка — вопрос команды и проекта, а не «что лучше».
- Из Java Kotlin виден по правилам: файл это класс
ИмяKt, companion без@JvmStaticэтоCompanion, свойства это геттеры, параметры по умолчанию требуют@JvmOverloads, исключения@Throws, suspend недоступен без обёртки. - Lombok в смешанном модуле не виден компилятору Kotlin: плагин
kotlin("plugin.lombok")или Java-код в отдельном модуле; замена наdata classэто конечная точка, а не первый шаг. - Раздел стоит в маршруте после Java, Spring и конкурентности: Kotlin не добавляет платформы, он меняет способ писать на знакомой, поэтому каждая тема опирается на её Java-разбор.
- Смешанный модуль это плагин
kotlin("jvm")плюс каталогsrc/main/kotlin; компилируется сначала Kotlin (читая исходники Java), потом Java, результат в одном каталоге классов — отсюда и беда с Lombok. - Начать проще всего из IntelliJ IDEA (ничего ставить не нужно) или с генератора проектов Spring Boot: он сам кладёт плагины,
kotlin-reflectи модуль Jackson для Kotlin. - Цена второго языка: сборка медленнее, инструментарий вне одной IDE беднее, найм уже, стектрейс и байткод читать труднее, версии плагинов и языка надо согласовывать.
- «Kotlin как Java» узнаётся по признакам: лишние типы с вопросом,
!!, ручные циклы,varи изменяемые коллекции по умолчанию,StringBuilder,ifкак инструкция.
Что почитать дальше
- Синтаксис Kotlin после Java — следующий шаг маршрута.
- Java: современные возможности — что уже умеет сама Java.
- Kotlin и Spring Boot — если хочется сразу к практике.