Kotlin for backend with Spring Boot

1. Overview

Kotlin is officially supported by Spring. Using Kotlin, you write less boilerplate, have null safety at compile time, and get data classes for DTOs. This guide teaches you to build a REST API managing orders (Order) using Kotlin and Spring Boot 3.x, including JPA entity, type-safe configuration, and testing. After reading, you’ll be able to initialize a Kotlin backend project yourself and avoid the most common mistakes when migrating from Java.

2. Maven Project Setup

2.1. Dependency

Besides familiar starters, a Kotlin project needs three more things. kotlin-stdlib is the standard library. kotlin-reflect is used by Spring to read Kotlin metadata. jackson-module-kotlin helps Jackson understand data classes and parameter default values.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.0</version>
</parent>

<properties>
    <java.version>21</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.module</groupId>
        <artifactId>jackson-module-kotlin</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

You don’t need to write versions for Kotlin artifacts. Spring Boot parent already manages kotlin.version. Parent also automatically sets kotlin.compiler.jvmTarget according to java.version, so the generated bytecode will target Java 21.

2.2. Configuring kotlin-maven-plugin

This is the most important part. In Kotlin, classes and methods are final by default. Spring needs to create proxies (CGLIB) for @Configuration, @Transactional, @Async…, so these classes must be open (open). Hibernate needs a no-argument constructor. Two compiler plugins allopen and noarg solve both problems:

<build>
    <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
    <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <configuration>
                <args>
                    <arg>-Xjsr305=strict</arg>
                </args>
                <compilerPlugins>
                    <plugin>spring</plugin>
                    <plugin>jpa</plugin>
                    <plugin>all-open</plugin>
                </compilerPlugins>
                <pluginOptions>
                    <option>all-open:annotation=jakarta.persistence.Entity</option>
                    <option>all-open:annotation=jakarta.persistence.MappedSuperclass</option>
                    <option>all-open:annotation=jakarta.persistence.Embeddable</option>
                </pluginOptions>
            </configuration>
            <dependencies>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-allopen</artifactId>
                    <version>${kotlin.version}</version>
                </dependency>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-noarg</artifactId>
                    <version>${kotlin.version}</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>

What each part means:

  • spring: automatically opens classes with @Component, @Configuration, @Transactional, @Async, @Cacheable, and derived annotations like @Service, @RestController.
  • jpa: generates a no-argument constructor for classes with @Entity, @Embeddable, @MappedSuperclass.
  • all-open with three options: also opens entities so Hibernate can create lazy loading proxies. The spring plugin doesn’t handle this.
  • -Xjsr305=strict: Kotlin reads Spring’s nullability annotations (@Nullable, @NonNullApi) as real Kotlin types. A Spring API returning @Nullable becomes T?, and you’re forced to handle null.

3. Application Startup Class

package dev.nonla.shop

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@SpringBootApplication
@ConfigurationPropertiesScan
class ShopApplication

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

runApplication<ShopApplication> is an extension function with reified generics. It’s equivalent to SpringApplication.run(ShopApplication::class.java, *args). The main function is at the top level; no need for companion object. @ConfigurationPropertiesScan will be used in section 7.

4. JPA Entity and Repository

package dev.nonla.shop.order

import jakarta.persistence.*
import java.math.BigDecimal

enum class OrderStatus { NEW, PAID, CANCELLED }

@Entity
@Table(name = "orders")
class Order(
    var customerName: String,
    var amount: BigDecimal,

    @Enumerated(EnumType.STRING)
    var status: OrderStatus = OrderStatus.NEW,

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
)

Some points to note:

  • Every field is declared in the primary constructor. You don’t need to write getters/setters.
  • id has type Long? and defaults to null, because the entity only has an id after being saved.
  • The no-argument constructor that Hibernate needs is generated by the jpa plugin. You don’t see it in the Kotlin code.
  • This is a regular class, not a data class. The reason is in section 9.

Repository is written concisely like Java:

package dev.nonla.shop.order

import org.springframework.data.jpa.repository.JpaRepository

interface OrderRepository : JpaRepository<Order, Long> {
    fun findByStatus(status: OrderStatus): List<Order>
}

5. DTO with Data Class and Null Safety

DTO is the right place to use data class. Kotlin automatically generates equals, hashCode, toString, and copy.

package dev.nonla.shop.order

import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Positive
import java.math.BigDecimal

data class CreateOrderRequest(
    @field:NotBlank
    val customerName: String,

    @field:Positive
    val amount: BigDecimal,
)

data class OrderResponse(
    val id: Long,
    val customerName: String,
    val amount: BigDecimal,
    val status: OrderStatus,
)

fun Order.toResponse() = OrderResponse(
    id = requireNotNull(id) { "Order has not been saved" },
    customerName = customerName,
    amount = amount,
    status = status,
)

Must write @field:NotBlank, not just @NotBlank. If there’s no use-site target field:, Kotlin attaches the annotation to the constructor parameter. Bean Validation then doesn’t see the annotation, and validation silently doesn’t run.

The extension function Order.toResponse() keeps the entity independent of the DTO, while still being callable as a method: order.toResponse(). requireNotNull throws IllegalArgumentException if id is null. After that line, the compiler treats id as Long, no longer as Long?.

6. Service and Controller

6.1. Service

package dev.nonla.shop.order

import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional(readOnly = true)
class OrderService(private val orderRepository: OrderRepository) {

    @Transactional
    fun create(request: CreateOrderRequest): OrderResponse {
        val order = Order(
            customerName = request.customerName.trim(),
            amount = request.amount,
        )
        return orderRepository.save(order).toResponse()
    }

    fun findById(id: Long): OrderResponse? =
        orderRepository.findByIdOrNull(id)?.toResponse()
}

The bean is injected via the primary constructor. With a class having a single constructor, you don’t need @Autowired. Fields are private val, so they can’t be reassigned.

findByIdOrNull is an extension from Spring Data specifically for Kotlin. It returns Order? instead of Optional<Order>. Combined with the ?. operator, the findById function fits in one line. The return type OrderResponse? also makes it clear to the caller that the result might not exist.

Thanks to the spring plugin, OrderService is automatically opened. Spring can create a proxy for @Transactional without you writing open class.

6.2. Controller

package dev.nonla.shop.order

import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

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

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun create(@Valid @RequestBody request: CreateOrderRequest): OrderResponse =
        orderService.create(request)

    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): ResponseEntity<OrderResponse> =
        orderService.findById(id)
            ?.let { ResponseEntity.ok(it) }
            ?: ResponseEntity.notFound().build()
}

The Elvis operator ?: replaces if (x != null) … else …: returns 200 if there’s an order, 404 if not. You can test with curl:

curl -s -X POST localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"John Doe","amount":250000}'
{"id":1,"customerName":"John Doe","amount":250000,"status":"NEW"}

If the request is missing customerName entirely, Jackson (with jackson-module-kotlin) reports an error during deserialization, because a non-null parameter has no value. Spring returns 400 Bad Request before validation even runs. If you send "customerName": " ", the request passes deserialization but is blocked by @field:NotBlank, and you also get 400.

7. Type-Safe Configuration with @ConfigurationProperties

payment.provider=vnpay
payment.timeout=3s
package dev.nonla.shop.payment

import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration

@ConfigurationProperties(prefix = "payment")
data class PaymentProperties(
    val provider: String,
    val timeout: Duration = Duration.ofSeconds(5),
    val retryCount: Int = 2,
)

From Spring Boot 3 onward, a class with a single constructor will be bound via constructor. You don’t need @ConstructorBinding. Properties not in the properties file (here retryCount) get Kotlin’s default values. Because it’s val, the configuration object is immutable after startup.

Inject into a service like any other bean:

@Service
class PaymentService(private val props: PaymentProperties) {
    fun describe() = "Paying via ${props.provider}, timeout ${props.timeout.seconds}s"
}
Paying via vnpay, timeout 3s

If payment.provider is missing (no default value), the application won’t start. This is what you want: configuration errors should be exposed at deployment time, not at runtime.

8. Writing Tests

spring-boot-starter-test already includes JUnit 5 and AssertJ. In Kotlin, you name tests using descriptions in backticks:

package dev.nonla.shop.order

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.math.BigDecimal

@DataJpaTest
@Import(OrderService::class)
class OrderServiceTest(@Autowired private val orderService: OrderService) {

    @Test
    fun `creates new order with NEW status and name trimmed`() {
        val response = orderService.create(
            CreateOrderRequest(customerName = "  Jane Smith ", amount = BigDecimal("99000"))
        )

        assertThat(response.id).isPositive()
        assertThat(response.customerName).isEqualTo("Jane Smith")
        assertThat(response.status).isEqualTo(OrderStatus.NEW)
    }

    @Test
    fun `returns null when order not found`() {
        assertThat(orderService.findById(9_999)).isNull()
    }
}

@DataJpaTest builds a small context containing only JPA and H2 in-memory. @Import adds OrderService to that context. The test class also uses constructor injection: in tests, JUnit 5 needs @Autowired on the parameter to know to get the value from Spring. Each test runs in a transaction and is rolled back when finished, so data doesn’t leak to other tests.

OrderServiceTest > creates new order with NEW status and name trimmed() PASSED
OrderServiceTest > returns null when order not found() PASSED

9. Common Mistakes

Using data class for entities. equals/hashCode are auto-generated based on all fields, including possibly-null id and lazy relations. As a result, the entity “changes hash” after being saved, breaking HashSet. toString() may trigger lazy loading or infinite recursion with bidirectional relations.

Note: Use data class only for DTOs and configuration. For entities, use a regular class. If needed, write equals/hashCode yourself based on id.

Forgetting the spring or all-open plugin. Without the plugin, Spring reports an error like @Configuration class may not be final. Worse, @Transactional on a final method is silently ignored. For entities, without all-open Hibernate can’t create proxies, and LAZY relations silently become EAGER.

Missing field: on validation annotations. Validation doesn’t run and no error is reported. Always write tests for invalid data to catch this mistake.

Using lateinit var for injection. This works, but you lose immutability and may get UninitializedPropertyAccessException. Prefer constructor injection with private val.

Forgetting -Xjsr305=strict. Without this flag, return types from Spring APIs are platform types (T!). The compiler doesn’t force you to check for null, and NullPointerException can still happen like in Java.

When shouldn’t you migrate to Kotlin yet? If no one on your team knows Kotlin and the project has tight deadlines, the learning cost (coroutines, extensions, scope functions) may outweigh the benefits. Kotlin code also compiles slightly slower than Java. Also, some libraries rely heavily on reflection, like Lombok or MapStruct in some configurations, don’t work well with Kotlin. In this case, you can introduce Kotlin gradually: write tests or new modules in Kotlin while keeping the old Java code.

10. Conclusion

You’ve built a Spring Boot 3 backend in Kotlin, consisting of:

  • configuring kotlin-maven-plugin with spring, jpa, all-open, and -Xjsr305=strict;
  • JPA entities using regular class, DTOs using data class with @field:;
  • services and controllers leveraging null safety, findByIdOrNull, and the Elvis operator;
  • immutable configuration using @ConfigurationProperties on data class;
  • tests with JUnit 5 and AssertJ, using descriptive names in backticks.

Most Spring knowledge you already have still applies. What to remember are three differences when migrating to Kotlin: classes are final by default, annotations need use-site targets, and nullable types must be declared explicitly. Master these three points and you’ll avoid most hard-to-find errors when migrating from Java to Kotlin.

kotlinspring-bootjparest-apibackend