Spring Boot поддерживает Kotlin из коробки: start.spring.io генерирует Kotlin-проект, документация Spring дублирует примеры на обоих языках. Но «поддерживает» не значит «одинаково»: у связки есть несколько мест, где Kotlin-умолчания сталкиваются со Spring-механикой. Разберём их по порядку — от сборки до тестов.

Обязательно

Сборка: что прописать до первого запуска

Kotlin-классы по умолчанию final, а Spring строит прокси наследованием: @Transactional, @Configuration, @Async требуют open-классов. Вручную писать open на каждом бине — тоскливо и забываемо, поэтому есть плагин:

plugins {
    kotlin("jvm") version "2.3.21"
    kotlin("plugin.spring") version "2.3.21"   // all-open для @Component, @Service, @Transactional...
    kotlin("plugin.jpa") version "2.3.21"      // no-arg конструкторы для @Entity
}

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-reflect")
}

kotlin {
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict")
    }
}

allOpen {                                      // ленивым связям Hibernate одного no-arg мало
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

plugin.spring автоматически делает open классы с корневыми Spring-аннотациями. plugin.jpa генерирует конструкторы без аргументов для JPA-сущностей — Hibernate без них не может создавать объекты.

Сущности он при этом open не делает, и это второй подводный камень. Для ленивой загрузки Hibernate подсовывает вместо объекта прокси, а строит его наследованием — значит ему нужен не-final класс и не-final геттеры. Отсюда блок allOpen с аннотациями JPA: без него FetchType.LAZY перестаёт быть ленивым, связь подтягивается сразу, и «почему запросов в десять раз больше» выясняется уже под нагрузкой.

Две оставшиеся строчки тоже не украшение. kotlin-reflect нужен самому Spring: по нему он разбирает конструктор — имена параметров, значения по умолчанию, nullability. Флаг -Xjsr305=strict заставляет компилятор считать nullability-аннотации Spring строгой ошибкой, а не предупреждением; без него вся польза из статьи про null-safety на границе со Spring теряется. Проект со start.spring.io приходит с обоими из коробки — дописывать приходится только в сборке, собранной руками.

Версию берите ту, что подставляет start.spring.io, и держите все плагины Kotlin на одной: расхождение версии компилятора и плагина ломает сборку невнятными сообщениями.

Что будет, если плагины забыть, зависит от места — и худший случай самый тихий. На @Configuration-классе Spring скажет прямо: «may not be final». Бину с @Transactional CGLIB бросит AopConfigException про final-класс. А вот final-метод внутри open-класса не проксируется молча: ни ошибки, ни предупреждения — транзакция просто не откроется, кеш не сработает, @Async выполнится синхронно. Ровно поэтому open ставят плагином, а не руками: плагин открывает и класс, и его методы.

без плагина @Service class final CGLIB-наследник отказ с plugin.spring @Service class open CGLIB-наследник прокси готов

Один и тот же бин с @Transactional: без плагина класс остаётся final и наследника построить не из чего, с plugin.spring он становится open и прокси создаётся; смотрите на второй столбец.

Что кладёт генератор проектов

Всё перечисленное выше можно не набирать руками: генератор start.spring.io с выбранным языком Kotlin кладёт это сам. Список полезен как чек-лист — по нему проверяют сборку, собранную до появления Kotlin в проекте.

Что появляетсяЗачем
плагин kotlin("jvm")компиляция Kotlin, каталоги src/main/kotlin и src/test/kotlin
плагин kotlin("plugin.spring")снимает final с классов и методов, помеченных аннотациями Spring
зависимость kotlin-reflectSpring разбирает по ней конструкторы, значения по умолчанию, nullability
зависимость kotlin-stdlibстандартная библиотека языка
jackson-module-kotlinдесериализация в классы с конструкторными параметрами
флаг -Xjsr305=strictnullability-аннотации библиотек считаются ошибкой, а не предупреждением
jvmTarget под выбранную версию Javaсогласованный байткод для двух компиляторов
сборка на Gradle с Kotlin DSL (build.gradle.kts)автодополнение и проверка типов в самом файле сборки
главный класс функцией верхнего уровняточка входа без класса-обёртки

Чего в сгенерированном проекте не будет, а понадобится почти сразу: плагин kotlin("plugin.jpa") и блок allOpen с аннотациями хранения — их добавляют при выборе зависимости для работы с базой через JPA, и о том, зачем они, сказано выше. Остальное — по надобности: kotlinx-coroutines-*, средства проверки стиля, springmockk для заглушек.

Главный класс в сгенерированном проекте выглядит так, и это стоит увидеть один раз:

@SpringBootApplication
class OrdersApplication

fun main(args: Array<String>) {
    runApplication<OrdersApplication>(*args)
}

Класс приложения пустой и объявлен без тела, main живёт вне класса, а runApplication — расширение с параметром типа вместо передачи OrdersApplication::class.java. Звёздочка перед args раскладывает массив в переменное число аргументов.

Инъекция: конструктор и никакой магии

@Service
class OrderService(
    private val repository: OrderRepository,
    private val events: ApplicationEventPublisher,
) {
    fun place(command: PlaceOrder): Order { /* ... */ }
}

Конструкторная инъекция в Kotlin выглядит естественно: зависимости — это val-свойства в первичном конструкторе, @Autowired не нужен. Побочный бонус: null-safety гарантирует, что зависимости не бывают null, а lateinit var с полевой инъекцией становится антипаттерном вдвойне — и по причинам DI, и по причинам системы типов.

Конфигурация: свойства классом и бины функциями

Два места, где конфигурация Spring на Kotlin выглядит иначе, чем на Java, и оба заметно приятнее.

@ConfigurationProperties: неизменяемый класс вместо набора сеттеров

На Java настройки описывают классом с полями и сеттерами. В Kotlin это data class с val, и связывание идёт через конструктор:

@ConfigurationProperties(prefix = "app.orders")
data class OrderProperties(
    val maxItems: Int = 50,
    val defaultCurrency: String = "RUB",
    val retry: Retry = Retry(),
) {
    data class Retry(
        val attempts: Int = 3,
        val delay: Duration = Duration.ofSeconds(1),
    )
}

Соответствующий файл настроек:

app:
  orders:
    max-items: 100
    retry:
      attempts: 5
      delay: 2s

Что здесь работает само и почему это лучше варианта с сеттерами:

  • Связывание через конструктор. Начиная со Spring Boot 3 отдельная аннотация для этого не нужна: если у класса один конструктор с параметрами, Spring связывает значения через него. Отдельно её указывают только тогда, когда конструкторов несколько, — тогда помечают нужный.
  • Свойства неизменяемые. val означает, что настройки нельзя поменять в рантайме случайным вызовом сеттера. На Java для этого нужна отдельная дисциплина.
  • Значения по умолчанию — из языка. Параметр со значением по умолчанию и есть значение по умолчанию настройки; отдельного места для него не нужно. Именно для этого Spring и требует kotlin-reflect: без него значения по умолчанию он не увидит.
  • Вложенные группы — вложенным классом. Никаких отдельных файлов и @NestedConfigurationProperty.
  • Отсутствие значения в настройках видно типом. val apiKey: String без значения по умолчанию означает «обязательно»: не нашли — приложение не поднимется. val apiKey: String? = null — «необязательно».

Класс нужно зарегистрировать — либо @EnableConfigurationProperties(OrderProperties::class) на конфигурации, либо @ConfigurationPropertiesScan на классе приложения. И проверять значения можно так же, как в запросах: @Validated на классе плюс @field:Min(1) на свойстве — тогда неверная настройка ломает старт, а не работу под нагрузкой.

Одна оговорка, которая стоит абзаца: связывание через конструктор не работает вместе с обновлением настроек на лету. Механизмы, которые перечитывают настройки в живом приложении, полагаются на сеттеры. Если такое обновление нужно, для этой группы настроек берут изменяемый класс — и это осознанный обмен, а не недоработка.

Бины функциями и описание контекста кодом

Обычный способ — @Bean-функции в классе конфигурации, и выглядит он компактнее java-варианта за счёт тел-выражений:

@Configuration(proxyBeanMethods = false)
class InfraConfig {

    @Bean
    fun clock(): Clock = Clock.systemUTC()

    @Bean
    fun orderClient(builder: RestClient.Builder, props: OrderProperties): OrderClient =
        OrderClient(builder.baseUrl(props.baseUrl).build())
}

proxyBeanMethods = false здесь не украшение: он говорит Spring не оборачивать класс конфигурации прокси, а значит снимает и требование к open. Ставить его стоит везде, где одна @Bean-функция не зовёт другую напрямую, — это и быстрее при старте, и меньше связи с механикой прокси.

Есть и второй способ, которого в Java нет: описание бинов кодом на встроенном языке.

fun beans() = beans {
    bean<OrderService>()
    bean<OrderRepositoryImpl>()
    bean {
        OrderClient(ref<RestClient.Builder>().baseUrl(ref<OrderProperties>().baseUrl).build())
    }
    profile("demo") {
        bean<StubPaymentGateway>()
    }
}

Подключается это при запуске:

fun main(args: Array<String>) {
    runApplication<OrdersApplication>(*args) {
        addInitializers(beans())
    }
}

Разница не косметическая. Бины здесь регистрируются без отражения и без сканирования пакетов: типы известны на этапе компиляции, зависимости берутся явным ref, ошибку «нет такого бина» видно раньше. Поэтому такой способ выбирают там, где важны быстрый старт и предсказуемость (небольшие сервисы, функции без сервера), а также в тестах — контекст описывается на месте, без сканирования лишнего. Цена — придётся перечислить бины руками; на большом сервисе со сканированием это неподъёмно, и там остаётся обычный @Configuration.

Смешивать оба способа можно: часть бинов аннотациями, часть — описанием кодом.

DTO: data class плюс Jackson

data class CreateOrderRequest(
    val customerId: String,
    val items: List<OrderItemDto>,
    val comment: String? = null,
)

Здесь работают сразу три вещи: nullability, десериализация и валидация. Nullability описывает контракт: comment может отсутствовать, customerId — обязателен. Модуль jackson-module-kotlin (Spring Boot подключает его автоматически, если он в classpath) учит Jackson звать первичный конструктор — и если в JSON нет обязательного поля, десериализация падает, а не проносит null внутрь.

Падает она наследником MismatchedInputException (в свежих версиях модуля это KotlinInvalidNullException, в старых был MissingKotlinParameterException); Spring заворачивает её в HttpMessageNotReadableException и сам отвечает 400. Свой обработчик нужен, только если хочется своего тела ошибки.

Но помните: nullability проверяет форму, не содержание. Пустая строка в customerId пройдёт — Bean Validation с @field:NotBlank никто не отменял:

data class CreateOrderRequest(
    @field:NotBlank val customerId: String,
    /* ... */
)

Префикс @field: говорит компилятору, куда поставить аннотацию: на поле, а не на параметр конструктора. Раньше это было обязательно — без префикса аннотация уезжала на параметр, и валидатор её не видел. С Kotlin 2.2 правила изменились: такая аннотация попадает и на параметр, и на поле сразу, в 2.4 это стало обычным поведением, а start.spring.io включает его флагом -Xannotation-default-target=param-property. Итог: на свежем проекте работает и без префикса, но явный @field: верен в любой версии — и читателю не приходится гадать, какой у вас компилятор.

Сущности хранения: почему не data class

Раздел про сборку вводит плагин, который делает сущности JPA работоспособными, а раздел про объекты передачи данных хвалит data class. Соединять это напрямую нельзя, и предупредить лучше здесь.

Сущность базы данных не делают data class. Плагин решает одну проблему — конструктор без аргументов, который требует Hibernate. Всё остальное, что генерирует data class, сущности вредит:

  • equals и hashCode считаются по всем конструкторным свойствам, а они у сущности изменяемые. Положили новый объект в множество, база присвоила идентификатор — и объект в множестве больше не находится.
  • Правильное равенство сущности — по идентификатору и только для сохранённых объектов; сгенерированное равенство по всем полям означает, что две загруженные копии одной строки с разным состоянием не равны.
  • Сгенерированные методы читают поля напрямую, минуя методы чтения. У отложенного прокси поля не заполнены, поэтому сравнение прокси с обычным объектом даёт «не равны», а hashCode у прокси считается не по тем данным.
  • toString печатает все свойства, включая отложенные связи, — вывод в лог превращается в неожиданный запрос к базе, а вне транзакции в исключение.
  • copy создаёт объект, который выглядит сущностью, но не связан с контекстом хранения: изменения в нём никуда не сохранятся.

Как выглядит рабочий вариант:

@Entity
@Table(name = "orders")
class OrderEntity(
    @Column(nullable = false) var customerId: String,
    @Column(nullable = false) var total: BigDecimal,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    var items: MutableList<OrderItemEntity> = mutableListOf()

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is OrderEntity) return false
        return id != null && id == other.id
    }

    override fun hashCode(): Int = id?.hashCode() ?: super.hashCode()

    override fun toString(): String = "OrderEntity(id=$id, customerId=$customerId)"
}

Что здесь важно по пунктам: обычный класс, а не data class; идентификатор — var с типом, допускающим отсутствие, потому что до сохранения его нет; равенство по идентификатору, причём несохранённый объект равен только сам себе; сравнение через !is, а не через сравнение классов, чтобы прокси не отсекался; toString руками и без отложенных связей.

Граница простая: data class — для объектов передачи данных и объектов-значений, обычный класс — для сущностей. Сущность в объект передачи данных превращает отдельная функция преобразования, и это не лишний слой, а то, что не даёт форме таблицы протечь в контракт эндпоинта.

Контроллеры: nullability как часть API

@RestController
@RequestMapping("/orders")
class OrderController(private val service: OrderService) {

    @GetMapping("/{id}")
    fun byId(@PathVariable id: String): OrderDto = service.byId(id)

    @GetMapping
    fun list(@RequestParam status: String?): List<OrderDto> = service.list(status)
}

Spring читает Kotlin-nullability: status: String? — необязательный параметр запроса, String — обязательный (иначе 400). Тип честно документирует контракт эндпоинта — это работает и в OpenAPI-генерации.

Тесты: почти без изменений

JUnit, MockMvc, Testcontainers — всё работает как в Java. Из мелких удобств сразу заметно одно — имена тестов можно писать по-человечески, в обратных кавычках:

@Test
fun `возвращает 404 когда заказ не найден`() { /* ... */ }

Второе привычное имя в Kotlin-тестах — mockk вместо Mockito. Старый довод «Mockito не умеет в final-классы» с пятой версии не работает: inline-mock-maker там включён по умолчанию, и Kotlin-классы мокаются без настройки. Причины выбрать mockk сегодня другие — удобный DSL и поддержка suspend-функций: coEvery и coVerify, которых у Mockito нет.

Kotlin в живом Java-проекте на Spring

Основной путь перехода — не новый сервис, а добавление Kotlin в работающий Java-сервис. Здесь есть порядок шагов, и отклонение от него стоит дня разбора невнятных ошибок сборки.

Шаг 1: плагины и каталог. В сборку добавляются kotlin("jvm") и kotlin("plugin.spring"), заводится src/main/kotlin и src/test/kotlin. Java-каталоги остаются на месте. Версия языка для всех плагинов Kotlin одна; целевая версия байткода — та же, что у Java-компиляции в этом модуле.

Шаг 2: решить вопрос с Lombok до первого Kotlin-файла. Это главная засада смешанного модуля, и она не про Spring, а про порядок компиляции: сначала компилируется Kotlin (он читает исходники Java), потом Java, и аннотации Lombok разворачиваются уже на втором шаге. Поэтому Kotlin-класс, зовущий order.getTotal() у Java-класса с аннотацией-генератором геттеров, не соберётся — метода для него ещё нет. Два выхода: плагин kotlin("plugin.lombok") вместе с файлом настроек Lombok, либо Java-код с Lombok в отдельном модуле, от которого зависит Kotlin-модуль.

Обратное направление работает нормально: Java видит готовый байткод Kotlin.

Шаг 3: обратный порядок тоже проверить. Java-код, который зовёт Kotlin, увидит непривычные имена: функции верхнего уровня как статические методы класса по имени файла, члены companion object через Companion, значения по умолчанию — никак. Если такие вызовы есть, на Kotlin-стороне ставят соответствующие аннотации; если нет — ничего делать не надо. Проверяется это одним Java-тестом, который зовёт новый Kotlin-API.

Шаг 4: первый класс — не самый важный, а самый проверяемый. Утилита без состояния, объект передачи данных, тест. Сканирование компонентов на Kotlin-бины работает как обычно: @Service на Kotlin-классе находится так же, а plugin.spring снимает с него final. Смешивать языки в одном пакете допустимо и даже удобно при переводе по частям.

Шаг 5: настройки, которые проявятся не сразу. Флаг -Xjsr305=strict — иначе nullability-аннотации библиотек останутся предупреждением; jackson-module-kotlin — иначе первый Kotlin-объект передачи данных с конструкторными параметрами не десериализуется; kotlin-reflect — иначе Spring не увидит значения по умолчанию в конструкторах. Ни одна из этих проблем не проявляется при компиляции, все три — в рантайме.

Шаг 6: сущности хранения — последними и руками. Плагин для конструкторов без аргументов и блок с открытием классов добавляют тогда, когда до сущностей действительно дойдёт дело, а не заранее. Причины из раздела выше: в сущности nullability и равенство — это решения о модели, а не перевод синтаксиса.

Чего делать не стоит: переводить весь модуль конвертером за один заход (получится «Java с другим синтаксисом», и ревью такого изменения невозможно) и начинать с доменного ядра (там больше всего решений, которые конвертер принять не может).

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

Дополнительно: при первом чтении можно пропустить

Глубже: корутины в Spring: suspend в контроллере и транзакциирасширенное

Статья про корутины заканчивается разделом «Корутины и Spring», а эта статья до корутин не доходит; вот стык, на котором обычно ломаются.

suspend в контроллере. В WebFlux suspend fun в контроллере поддерживаются давно: запрос обрабатывается корутиной, поток не занят на ожидании. В Spring MVC поддержка suspend-функций в контроллерах появилась в Spring Framework 6.1: запрос переводится в асинхронный режим, корутина выполняется, ответ отдаётся по завершении. Работает, но выигрыша «не держать поток» в MVC с блокирующим JDBC внутри нет: сама база всё равно занимает поток. Отсюда правило: MVC с JDBC, значит обычные функции и виртуальные потоки за дешёвым ожиданием; WebFlux с R2DBC, значит suspend насквозь.

@Transactional и смена потока. Транзакция JDBC привязана к потоку через ThreadLocal; suspend-функция после точки приостановки может продолжиться в другом потоке, и @Transactional suspend fun над JDBC теряет транзакцию молча: запросы после withContext идут вне неё. С реактивным менеджером транзакций (R2DBC) Spring держит контекст транзакции в контексте корутины, и @Transactional на suspend-функции работает. Итог тот же, что выше: транзакции JDBC в обычных функциях, транзакции R2DBC в suspend.

Тесты. runTest из kotlinx-coroutines-test для suspend-функций сервисов, runBlocking там, где тестовая обёртка не понимает suspend (некоторые расширения JUnit), coEvery и coVerify у mockk для suspend-зависимостей, о чём раздел про тесты выше; WebTestClient для контроллеров WebFlux, MockMvc с асинхронной диспетчеризацией для suspend-контроллеров MVC.

Практический вывод для команды, переходящей на Kotlin в Spring-сервисе: корутины это не часть перехода на язык, а отдельное решение про стек (WebFlux и R2DBC), и его принимают по причинам из статьи про MVC против WebFlux, а не потому, что suspend красиво выглядит.

Глубже: тестирование на Kotlin: JUnit, mockk, kotest и Testcontainersрасширенное

Пять строк про тесты в конце статьи стоит развернуть, потому что тесты на Kotlin отличаются от Java больше, чем сам код.

JUnit 5 остаётся. Те же @Test, @SpringBootTest, @WebMvcTest, Testcontainers с @Container и @ServiceConnection работают без изменений; имена тестов пишут в обратных кавычках предложением, fun \confirmed order rejects new lines`(), и это читается лучше любых@DisplayName. Утверждения: AssertJ работает,kotlin-testдаётassertEquals, kotest-assertions даётshouldBeиshouldThrow`, и их берут даже без самого kotest.

mockk. Mockito не дружит с final-классами Kotlin по умолчанию (всё в Kotlin final, пока не open) и с suspend-функциями; mockk понимает и то, и другое: every { repo.find(id) } returns order, coEvery для suspend, verify и coVerify, mockk(relaxed = true) для заглушек без настройки, mockkStatic для extension-функций верхнего уровня, slot() для захвата аргумента. С Spring это @MockkBean из springmockk вместо @MockitoBean.

kotest как альтернатива JUnit: стили описания тестов (StringSpec, BehaviorSpec с given/when/then), property-тесты (checkAll генерирует входы), табличные тесты; сильнее там, где тестов много и они однотипны. Смешивать в одном проекте не стоит, а начинать проще с JUnit и kotest-assertions.

suspend в тестах. runTest заменяет runBlocking и умеет пропускать задержки виртуальным временем, TestScope даёт контроль над корутинами теста; тест suspend-функции без runTest не скомпилируется, и это первое сообщение об ошибке, которое увидит джавист.

Глубже: инструменты и стиль: ktlint, detekt и конвертер из Javaрасширенное

Практическая половина перехода это не язык, а привычки, которые в Kotlin другие, и инструменты, которые их держат.

Официальный стиль. В gradle.properties строка kotlin.code.style=official переключает IDE на стандартный стиль (отступы, переносы, порядок модификаторов); без неё IDE применяет старый стиль по умолчанию, и код от двух разработчиков расходится. ktlint проверяет и правит форматирование по этому стилю в сборке (ktlintCheck, ktlintFormat), без настроек и споров. detekt это статический анализ: сложность функций, длинные методы, имена, неиспользуемое, запрещённые вызовы (!!, println), с базовым файлом существующих нарушений, чтобы включить в старом проекте и ловить только новые. Оба в конвейере рядом с тестами.

Конвертер Java в Kotlin. IDE переводит файл за секунду, и результат всегда правят руками, потому что конвертер не знает намерений. Он расставляет !! там, где не уверен в nullability, оставляет платформенные типы, переводит циклы буквально, а не в map и filter, делает var там, где было бы val, и превращает Java-классы данных в классы с гетерами вместо data class.

Порядок: конвертировать файл, прогнать тесты, пройти по каждому !! и решить nullability осознанно, заменить циклы на функции коллекций, сделать val и data class, где это по смыслу, прогнать detekt. Начинают с тестов и утилит, доменные классы переводят последними и вручную, потому что в них nullability это решение о модели, а не о синтаксисе.

Коротко

  • Обязательное в сборке: kotlin("plugin.spring") — open для Spring-прокси, kotlin("plugin.jpa") — no-arg конструкторы сущностей, блок allOpen с аннотациями JPA — чтобы работала ленивая загрузка, плюс kotlin-reflect и флаг -Xjsr305=strict.
  • Инъекция — через первичный конструктор: val-зависимости, без @Autowired и без lateinit. DTO — data class: nullability задаёт обязательность полей, jackson-module-kotlin проверяет её при десериализации и отдаёт 400, Bean Validation — с префиксом @field: (на Kotlin 2.4 он уже не обязателен, но лишним не будет).
  • В контроллерах String? у параметра = необязательный, String = обязательный: тип и есть контракт. Тесты — та же экосистема; mockk берут ради DSL и coEvery/coVerify под suspend-функции, а не ради final-классов: их Mockito 5 мокает сам.
  • Корутины в Spring это решение о стеке: WebFlux и R2DBC дают suspend насквозь с рабочим @Transactional, MVC с JDBC теряет транзакцию при смене потока, и там честнее обычные функции с виртуальными потоками.
  • Тесты: JUnit 5 и Testcontainers без изменений, имена в обратных кавычках, mockk с coEvery и @MockkBean вместо Mockito, kotest как вариант, runTest для suspend. Стиль держат kotlin.code.style=official, ktlint и detekt с базовым файлом; конвертер из Java даёт черновик с !! и циклами, который правят по порядку: nullability, коллекции, val, data class.
  • Генератор проектов кладёт плагины kotlin("jvm") и plugin.spring, kotlin-reflect, модуль Jackson для Kotlin и флаг строгой nullability; плагин для сущностей и блок открытия классов хранения добавляют сами.
  • Настройки описывают data class с val: связывание идёт через конструктор без отдельной аннотации, значения по умолчанию берутся из языка, вложенные группы — вложенным классом; обновление настроек на лету при этом не работает.
  • Бины пишут @Bean-функциями с proxyBeanMethods = false либо описывают контекст кодом через beans { } — без отражения и сканирования, что полезно для быстрого старта и тестов.
  • Сущность хранения не делают data class: равенство по изменяемым полям, чтение полей минуя прокси, toString по отложенным связям и copy вне контекста; пишут обычный класс с равенством по идентификатору.
  • Добавление Kotlin в живой Java-сервис идёт по шагам: плагины и каталог, решение про Lombok до первого файла, проверка вызовов из Java, первый класс попроще, три настройки, проявляющиеся только в рантайме, и сущности последними.

Что пощупать

Смешанный модуль из статьи существует в практикуме remodov/marketplace-system: сервис уведомлений написан на Java, а подстановка переменных в шаблон письма — на Kotlin, в том же модуле Gradle, с тестом тоже на Kotlin. На одном классе видно, чем data class отличается от record, как элвис закрывает пустое поле шаблона и что ломается на стыке имён методов при вызове из Java.

Код: TemplateRenderer.kt, сервис уведомлений.

Сделаем сами

Ветка side-b1-kotlin-notification — подстановка ${var} не перенесена с Java, TemplateRendererTest на Kotlin красный, условие по ссылке в TASK.md.

Что почитать дальше