Kotlin für Backend mit Spring Boot
1. Übersicht
Kotlin ist eine von Spring offiziell unterstützte Sprache. Mit Kotlin schreiben Sie weniger Boilerplate-Code, profitieren von Null Safety zur Compile-Zeit und nutzen Datenklassen für DTOs. Dieser Artikel führt Sie durch die Erstellung einer REST-API zur Verwaltung von Bestellungen (Order) mit Kotlin und Spring Boot 3.x – einschließlich JPA-Entitäten, typsicherer Konfiguration und Tests. Danach werden Sie in der Lage sein, ein Kotlin-Backend-Projekt selbstständig zu initialisieren und die häufigsten Fehler beim Wechsel von Java zu vermeiden.
2. Maven-Projektsetup
2.1. Abhängigkeiten
Neben den bekannten Startern benötigt ein Kotlin-Projekt drei zusätzliche Dependencies. kotlin-stdlib ist die Kotlin-Standardbibliothek. kotlin-reflect wird von Spring benötigt, um Kotlin-Metadaten zu lesen. jackson-module-kotlin hilft Jackson, Datenklassen und Standardwerte von Parametern zu verstehen.
<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>
Sie müssen keine Versionen für die Kotlin-Artefakte angeben. Das Spring-Boot-Parent verwaltet kotlin.version. Das Parent setzt auch automatisch kotlin.compiler.jvmTarget nach java.version, daher wird der erzeugte Bytecode auf Java 21 abzielen.
2.2. Konfiguration des kotlin-maven-plugin
Dieser Abschnitt ist entscheidend. In Kotlin sind Klassen und Methoden standardmäßig final. Spring benötigt aber die Möglichkeit, Proxys (CGLIB) für @Configuration, @Transactional, @Async… zu erzeugen, daher müssen diese Klassen open sein. Hibernate benötigt einen parameterlosen Konstruktor. Zwei Compiler-Plugins – allopen und noarg – lösen beide Probleme:
<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>
Bedeutung der einzelnen Teile:
spring: öffnet automatisch Klassen mit@Component,@Configuration,@Transactional,@Async,@Cacheableund abgeleiteten Annotations wie@Service,@RestController.jpa: erzeugt einen parameterlosen Konstruktor für Klassen mit@Entity,@Embeddable,@MappedSuperclass.all-openmit drei Optionen: öffnet auch Entitäten, damit Hibernate Proxys für Lazy Loading erstellen kann. Das Pluginspringkümmert sich nicht darum.-Xjsr305=strict: Kotlin interpretiert Spring-Nullability-Annotations (@Nullable,@NonNullApi) als echte Kotlin-Typen. Eine Spring-API, die@Nullablezurückgibt, wird zuT?, und der Compiler zwingt Sie, null zu behandeln.
3. Anwendungs-Einstiegsklasse
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> ist eine Extension-Funktion mit reified Generic. Sie entspricht SpringApplication.run(ShopApplication::class.java, *args). Die main-Funktion befindet sich auf Top-Level – Sie brauchen kein companion object. @ConfigurationPropertiesScan wird in Abschnitt 7 verwendet.
4. JPA-Entity und 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,
)
Einige wichtige Punkte:
- Alle Felder sind im Primary Constructor deklariert. Sie müssen keine Getter/Setter schreiben.
idhat den TypLong?und Standardwertnull, da die Entity erst nach dem Speichern eine ID hat.- Der parameterlose Konstruktor, den Hibernate benötigt, wird durch das Plugin
jpaerzeugt. Sie sehen ihn nicht im Kotlin-Code. - Das ist eine gewöhnliche
class, keinedata class. Der Grund wird in Abschnitt 9 erläutert.
Das Repository ist so kurz wie in 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. DTOs mit Datenklassen und Null Safety
Datenklassen sind der richtige Platz für DTOs. Kotlin erzeugt equals, hashCode, toString und copy automatisch.
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 wurde noch nicht gespeichert" },
customerName = customerName,
amount = amount,
status = status,
)
Sie müssen @field:NotBlank schreiben, nicht @NotBlank. Ohne das Use-Site-Target field: hängt Kotlin die Annotation am Konstruktor-Parameter an. Bean Validation sieht die Annotation dann nicht, und die Validierung läuft lautlos nicht ab.
Die Extension-Funktion Order.toResponse() hält die Entity von der DTO unabhängig und erlaubt trotzdem, sie wie eine Methode aufzurufen: order.toResponse(). requireNotNull wirft IllegalArgumentException, wenn id null ist. Danach behandelt der Compiler id als Long, nicht mehr als Long?.
6. Service und 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()
}
Beans werden über den Primary Constructor injiziert. Bei einer Klasse mit nur einem Konstruktor brauchen Sie kein @Autowired. Das Feld ist private val, kann also nicht neu zugewiesen werden.
findByIdOrNull ist eine Spring-Data-Extension speziell für Kotlin. Sie gibt Order? statt Optional<Order> zurück. Kombiniert mit dem Elvis-Operator ?. ist die findById-Funktion in einer Zeile lesbar. Der Rückgabetyp OrderResponse? macht auch klar, dass das Ergebnis nicht vorhanden sein kann.
Dank des Plugins spring wird die OrderService-Klasse automatisch geöffnet. Spring erzeugt einen Proxy für @Transactional, ohne dass Sie open class schreiben müssen.
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()
}
Der Elvis-Operator ?: ersetzt if (x != null) … else …: Wenn es eine Bestellung gibt, 200 zurückgeben, sonst 404. Probieren Sie es mit curl:
curl -s -X POST localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerName":"Nguyễn Văn An","amount":250000}'
{"id":1,"customerName":"Nguyễn Văn An","amount":250000,"status":"NEW"}
Wenn die Request customerName ganz vermissen lässt, meldet Jackson (mit jackson-module-kotlin) einen Fehler beim Deserialisieren, weil der Non-Null-Parameter keinen Wert hat. Spring gibt 400 Bad Request zurück, noch bevor die Validierung läuft. Wenn Sie "customerName": " " senden, passiert das Deserialisieren, aber @field:NotBlank blockiert den Request, und Sie erhalten ebenfalls 400.
7. Typsichere Konfiguration mit @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,
)
Ab Spring Boot 3 wird eine Klasse mit genau einem Konstruktor über den Konstruktor gebunden. Sie brauchen nicht @ConstructorBinding. Eigenschaften, die nicht in der Properties-Datei vorhanden sind (hier: retryCount), erhalten den Kotlin-Standardwert. Weil es val ist, ist das Konfigurationsobjekt nach dem Start unveränderlich.
Injizieren Sie es als normales Bean in den Service:
@Service
class PaymentService(private val props: PaymentProperties) {
fun describe() = "Zahlung über ${props.provider}, Timeout ${props.timeout.seconds}s"
}
Zahlung über vnpay, Timeout 3s
Wenn payment.provider fehlt (kein Standardwert), startet die Anwendung nicht. Das ist das Verhalten, das Sie wollen: Konfigurationsfehler sollten sofort beim Deployment sichtbar werden, nicht erst zur Laufzeit.
8. Tests schreiben
spring-boot-starter-test enthält bereits JUnit 5 und AssertJ. In Kotlin können Sie Testnamen als beschreibende Sätze in Backticks setzen:
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 `neue Bestellung mit Status NEW erstellen und Name wird getrimmt`() {
val response = orderService.create(
CreateOrderRequest(customerName = " Trần Thị Bình ", amount = BigDecimal("99000"))
)
assertThat(response.id).isPositive()
assertThat(response.customerName).isEqualTo("Trần Thị Bình")
assertThat(response.status).isEqualTo(OrderStatus.NEW)
}
@Test
fun `gibt null zurück, wenn Bestellung nicht gefunden wird`() {
assertThat(orderService.findById(9_999)).isNull()
}
}
@DataJpaTest erstellt einen kleinen Context mit nur JPA und H2 in-memory. @Import fügt OrderService zu diesem Context hinzu. Die Test-Klasse nutzt auch Constructor-Injection: In Tests benötigt JUnit 5 @Autowired auf dem Parameter, um zu wissen, dass er aus Spring kommen soll. Jeder Test läuft in einer Transaktion und wird nach dem Ende zurückgerollt, so dass Daten nicht zu anderen Tests durchsickern.
OrderServiceTest > neue Bestellung mit Status NEW erstellen und Name wird getrimmt() PASSED
OrderServiceTest > gibt null zurück, wenn Bestellung nicht gefunden wird() PASSED
9. Häufige Fehler
data class für Entitäten verwenden. equals/hashCode werden automatisch auf Basis aller Felder erzeugt, einschließlich id (das null sein kann) und Lazy-Loading-Relationen. Die Folge: Die Entity “ändert ihren Hash” nach dem Speichern, was HashSet beschädigt. toString() kann Lazy Loading auslösen oder zu unendlicher Rekursion mit bidirektionalen Relationen führen.
Merksatz:
data classnur für DTOs und Konfiguration verwenden. Bei Entitäten reguläreclassverwenden. Falls nötig,equals/hashCodeselbst auf Basis vonidimplementieren.
Plugin spring oder all-open vergessen. Ohne das Plugin meldet Spring Fehler wie @Configuration class may not be final. Schlimmer noch: @Transactional auf final Methoden wird stillschweigend ignoriert, ohne Warnung. Bei Entitäten kann Hibernate ohne all-open keine Proxys erstellen, und LAZY-Relationen werden still zu EAGER.
field: bei Validation-Annotations vergessen. Validierung läuft nicht und es wird keine Fehlermeldung gezeigt. Schreiben Sie immer Tests für ungültige Daten, um diesen Fehler zu erwischen.
lateinit var für Injection verwenden. Dies funktioniert, aber Sie verlieren Unveränderlichkeit und riskieren UninitializedPropertyAccessException. Verwenden Sie stattdessen Constructor-Injection mit private val.
-Xjsr305=strict vergessen. Ohne dieses Flag sind Rückgabetypen von Spring-APIs Platform Types (T!). Der Compiler zwingt Sie nicht, null zu prüfen, und NullPointerException kann wie in Java vorkommen.
Wann sollte man noch nicht zu Kotlin wechseln? Wenn niemand in Ihrem Team Kotlin kennt und das Projekt unter Zeitdruck steht, könnten die Lernkosten (Coroutines, Extensions, Scope Functions) größer sein als der Nutzen. Kotlin-Code kompiliert auch etwas langsamer als Java. Außerdem funktionieren manche Libraries, die stark auf Reflection setzen (wie Lombok oder MapStruct in bestimmten Setups), mit Kotlin nicht optimal. In solchen Fällen können Sie Kotlin schrittweise einführen: neue Tests oder Module in Kotlin schreiben, während Sie den alten Java-Code bewahren.
10. Fazit
Sie haben ein Spring-Boot-3-Backend mit Kotlin erstellt, mit:
- Konfiguration des
kotlin-maven-pluginmitspring,jpa,all-openund-Xjsr305=strict; - JPA-Entitäten als gewöhnliche
class, DTOs alsdata classmit@field:; - Services und Controller, die Null Safety,
findByIdOrNullund den Elvis-Operator nutzen; - unveränderliche Konfiguration via
@ConfigurationPropertiesaufdata class; - Tests mit JUnit 5 und AssertJ, mit beschreibenden Namen in Backticks.
Die meisten Spring-Kenntnisse, die Sie bereits haben, sind direkt anwendbar. Das Wichtigste beim Wechsel von Java zu Kotlin sind drei Unterschiede: Klassen sind standardmäßig final, Annotations brauchen Use-Site-Targets, und Nullable-Typen müssen explizit deklariert werden. Wenn Sie diese drei Punkte beherrschen, vermeiden Sie die meisten schwer zu findenden Fehler beim Wechsel von Java zu Kotlin.