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 ставят плагином, а не руками: плагин открывает и класс, и его методы.
Один и тот же бин с @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-reflect | Spring разбирает по ней конструкторы, значения по умолчанию, nullability |
зависимость kotlin-stdlib | стандартная библиотека языка |
jackson-module-kotlin | десериализация в классы с конструкторными параметрами |
флаг -Xjsr305=strict | nullability-аннотации библиотек считаются ошибкой, а не предупреждением |
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.
Что почитать дальше
- Зачем Java-разработчику Kotlin — если пришли сюда без начала маршрута.
- Ядро Spring: DI и жизненный цикл — механика, на которую всё это опирается.
- Валидация запросов — контракт эндпоинта глубже, чем nullability.