Spring Boot поддерживает Kotlin из коробки: start.spring.io генерирует Kotlin-проект, документация Spring дублирует примеры на обоих языках. Но «поддерживает» не значит «одинаково»: у связки есть несколько мест, где Kotlin-умолчания сталкиваются со Spring-механикой. Разберём их по порядку — от сборки до тестов.
Сборка: два плагина, которые не обсуждаются
Kotlin-классы по умолчанию final, а Spring строит прокси наследованием: @Transactional, @Configuration, @Async требуют open-классов. Вручную писать open на каждом бине — тоскливо и забываемо, поэтому есть плагин:
plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.spring") version "2.0.0" // all-open для @Component, @Service, @Transactional...
kotlin("plugin.jpa") version "2.0.0" // no-arg конструкторы для @Entity
}
plugin.spring автоматически делает open классы с корневыми Spring-аннотациями. plugin.jpa генерирует конструкторы без аргументов для JPA-сущностей — Hibernate без них не может создавать объекты. Забыли плагины — получите загадочные ошибки прокси на старте; это самая частая засада первого Kotlin-сервиса.
Инъекция: конструктор и никакой магии
@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, и по причинам системы типов.
DTO: data class плюс Jackson
data class CreateOrderRequest(
val customerId: String,
val items: List<OrderItemDto>,
val comment: String? = null,
)
Здесь работают сразу три вещи. Nullability описывает контракт: comment может отсутствовать, customerId — обязателен. Модуль jackson-module-kotlin (Spring Boot подключает его автоматически, если он в classpath) учит Jackson звать первичный конструктор — и если в JSON нет обязательного поля, десериализация упадёт с понятной ошибкой, а не пронесёт null внутрь.
Но помните: nullability проверяет форму, не содержание. Пустая строка в customerId пройдёт — Bean Validation с @field:NotBlank никто не отменял:
data class CreateOrderRequest(
@field:NotBlank val customerId: String,
/* ... */
)
Префикс @field: важен: без него аннотация уедет на параметр конструктора, и валидатор её не увидит.
Контроллеры: 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. Kotlin добавляет удобства: имена тестов в бэктиках и mockk как идиоматичная альтернатива Mockito (у Mockito с final-классами и nullability шероховатости):
@Test
fun `возвращает 404 когда заказ не найден`() { /* ... */ }
Коротко
- Обязательные плагины сборки:
kotlin("plugin.spring")— open для Spring-прокси,kotlin("plugin.jpa")— no-arg конструкторы сущностей. - Инъекция — через первичный конструктор: val-зависимости, без @Autowired и без lateinit.
- DTO — data class: nullability задаёт обязательность полей, jackson-module-kotlin проверяет её при десериализации, Bean Validation — с префиксом
@field:. - В контроллерах
String?у параметра = необязательный,String= обязательный: тип и есть контракт. - Тесты — та же экосистема; mockk дружит с final-классами лучше Mockito.
Что почитать дальше
- Зачем Java-разработчику Kotlin — если пришли сюда без начала маршрута.
- Ядро Spring: DI и жизненный цикл — механика, на которую всё это опирается.
- Валидация запросов — контракт эндпоинта глубже, чем nullability.