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.