Kotlin pour le backend avec Spring Boot
1. Aperçu
Kotlin est un langage officiellement pris en charge par Spring. Avec Kotlin, vous écrivez moins de code passe-partout, vous avez la sécurité null à la compilation et disposez des data classes pour les DTO. Cet article vous guide dans la construction d’une REST API de gestion de commandes (Order) en Kotlin et Spring Boot 3.x, incluant les entités JPA, la configuration type-safe et les tests. Après sa lecture, vous pourrez initialiser par vous-même un projet backend en Kotlin et éviter les erreurs les plus courantes lors de la migration depuis Java.
2. Configuration du projet Maven
2.1. Dépendances
Au-delà des starters familiers, un projet Kotlin nécessite trois dépendances supplémentaires. kotlin-stdlib est la bibliothèque standard. kotlin-reflect est utilisée par Spring pour lire les métadonnées Kotlin. jackson-module-kotlin aide Jackson à comprendre les data classes et les valeurs par défaut des paramètres.
<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>
Vous n’avez pas besoin de spécifier les versions pour les artefacts Kotlin. Le parent Spring Boot gère déjà kotlin.version. Le parent définit également automatiquement kotlin.compiler.jvmTarget selon java.version, de sorte que le bytecode généré ciblera Java 21.
2.2. Configuration du kotlin-maven-plugin
Cette partie est la plus importante. En Kotlin, les classes et les méthodes sont final par défaut. Spring doit créer des proxies (CGLIB) pour @Configuration, @Transactional, @Async…, donc ces classes doivent être ouvertes (open). Hibernate a besoin d’un constructeur sans paramètres. Les deux plugins de compilateur allopen et noarg résolvent tous les deux problèmes :
<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>
Signification de chaque partie :
spring: ouvre automatiquement les classes marquées avec@Component,@Configuration,@Transactional,@Async,@Cacheableet les annotations dérivées comme@Service,@RestController.jpa: génère un constructeur sans paramètres pour les classes marquées avec@Entity,@Embeddable,@MappedSuperclass.all-openavec trois options : ouvre également les entités pour que Hibernate puisse créer des proxies de chargement différé. Le pluginspringne gère pas cette partie.-Xjsr305=strict: Kotlin interprète les annotations de nullabilité de Spring (@Nullable,@NonNullApi) comme de vrais types Kotlin. Une API Spring retournant@NullabledevientT?, et vous êtes obligé de gérer les null.
3. Classe de démarrage de l’application
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> est une fonction d’extension avec un générique reifié. Elle est équivalente à SpringApplication.run(ShopApplication::class.java, *args). La fonction main est au niveau supérieur, aucun besoin de companion object. @ConfigurationPropertiesScan sera utilisé à la section 7.
4. Entité JPA et 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,
)
Quelques points à noter :
- Tous les champs sont déclarés directement dans le constructeur primaire. Vous n’avez pas besoin d’écrire les getters/setters.
ida le typeLong?et vautnullpar défaut, car l’entité n’a un id que après avoir été sauvegardée.- Le constructeur sans paramètres que Hibernate nécessite est généré par le plugin
jpa. Vous ne le voyez pas dans le code Kotlin. - Il s’agit d’une classe ordinaire, pas une
data class. La raison se trouve à la section 9.
Le repository s’écrit de manière concise comme en 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 avec data classes et sécurité null
C’est l’endroit idéal pour utiliser les data classes dans les DTO. Kotlin génère automatiquement equals, hashCode, toString et 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 chưa được lưu" },
customerName = customerName,
amount = amount,
status = status,
)
Vous devez écrire @field:NotBlank et non pas @NotBlank. Sans la cible de site d’utilisation field:, Kotlin attache l’annotation au paramètre du constructeur. La validation de bean ne voit alors pas l’annotation, et la validation s’exécute silencieusement pas.
La fonction d’extension Order.toResponse() permet à l’entité de ne pas dépendre du DTO, tout en l’appelant comme une méthode : order.toResponse(). requireNotNull lève une IllegalArgumentException si id est null. Après cette ligne, le compilateur considère id comme un Long, pas un Long?.
6. Service et 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()
}
Le bean est injecté via le constructeur primaire. Avec une classe ayant un seul constructeur, vous n’avez pas besoin de @Autowired. Le champ est private val, donc il ne peut pas être réassigné.
findByIdOrNull est une extension de Spring Data spécifique à Kotlin. Elle retourne Order? au lieu de Optional<Order>. Combiné avec l’opérateur ?., la fonction findById tient sur une seule ligne. Le type de retour OrderResponse? indique clairement à l’appelant que le résultat pourrait ne pas exister.
Grâce au plugin spring, OrderService est automatiquement ouvert. Spring peut créer un proxy pour @Transactional sans que vous ayez besoin d’écrire 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()
}
L’opérateur Elvis ?: remplace if (x != null) … else … : s’il y a une commande, retournez 200, sinon retournez 404. Vous pouvez l’essayer avec 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"}
Si la requête manque entièrement de customerName, Jackson (avec jackson-module-kotlin) signale une erreur lors de la désérialisation, car le paramètre non-null n’a pas de valeur. Spring retourne 400 Bad Request avant même l’étape de validation. Si vous envoyez "customerName": " ", la requête passe l’étape de désérialisation mais est bloquée par @field:NotBlank, et vous recevez également 400.
7. Configuration type-safe avec @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,
)
À partir de Spring Boot 3, une classe avec un seul constructeur sera liée via le constructeur. Vous n’avez pas besoin de @ConstructorBinding. Les propriétés qui ne sont pas dans le fichier properties (ici retryCount) reçoivent les valeurs par défaut de Kotlin. Comme il s’agit de val, l’objet de configuration est immuable après l’initialisation.
Injectez-le dans le service comme un bean ordinaire :
@Service
class PaymentService(private val props: PaymentProperties) {
fun describe() = "Thanh toán qua ${props.provider}, timeout ${props.timeout.seconds}s"
}
Thanh toán qua vnpay, timeout 3s
Si payment.provider est manquant (pas de valeur par défaut), l’application ne démarrera pas. C’est le comportement souhaité : les erreurs de configuration doivent être révélées au moment du déploiement, pas à l’exécution.
8. Écrire des tests
spring-boot-starter-test inclut déjà JUnit 5 et AssertJ. En Kotlin, vous nommez les tests avec une phrase descriptive entre 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 `tạo đơn hàng mới với trạng thái NEW và tên đã được trim`() {
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 `trả về null khi không tìm thấy đơn hàng`() {
assertThat(orderService.findById(9_999)).isNull()
}
}
@DataJpaTest crée un contexte léger contenant seulement JPA et H2 en mémoire. @Import ajoute OrderService à ce contexte. La classe de test utilise également l’injection de constructeur : dans un test, JUnit 5 a besoin de @Autowired sur le paramètre pour savoir récupérer la valeur de Spring. Chaque test s’exécute dans une transaction et est restauré à la fin, de sorte que les données ne fuient pas vers d’autres tests.
OrderServiceTest > tạo đơn hàng mới với trạng thái NEW và tên đã được trim() PASSED
OrderServiceTest > trả về null khi không tìm thấy đơn hàng() PASSED
9. Erreurs courantes
Utiliser data class pour les entités. equals/hashCode généré automatiquement en fonction de tous les champs, y compris id qui peut être null et les relations lazy. Le résultat est que l’entité change de hash après avoir été sauvegardée, ce qui corrompt HashSet. toString() peut déclencher un chargement lazy ou une récursion infinie avec les relations bidirectionnelles.
Remarque : N’utilisez
data classque pour les DTO et la configuration. Pour les entités, utilisez une classe ordinaire. Si nécessaire, écrivez vous-mêmeequals/hashCodeen fonction deid.
Oublier le plugin spring ou all-open. Sans le plugin, Spring signale une erreur comme @Configuration class may not be final. Pire encore, @Transactional sur une méthode final est silencieusement ignoré. Avec les entités, sans all-open, Hibernate ne peut pas créer de proxies, et les relations LAZY deviennent silencieusement EAGER.
Manquer field: sur les annotations de validation. La validation ne s’exécute pas et aucune erreur n’est signalée. Écrivez toujours des tests pour les cas de données invalides pour détecter cette erreur.
Utiliser lateinit var pour l’injection. Cette approche fonctionne, mais vous perdez l’immuabilité et risquez une UninitializedPropertyAccessException. Préférez l’injection de constructeur avec private val.
Oublier -Xjsr305=strict. Sans ce flag, les types de retour de l’API Spring sont des types de plateforme (T!). Le compilateur ne vous force pas à vérifier les null, et NullPointerException peut toujours se produire comme en Java.
Quand ne devriez-vous pas encore migrer vers Kotlin? Si votre équipe n’est pas familiarisée avec Kotlin et que le projet a des délais serrés, le coût d’apprentissage (coroutines, extensions, fonctions de portée) peut dépasser les avantages. Le code Kotlin compile également un peu plus lentement que Java. De plus, certaines bibliothèques basées sur la réflexion, comme Lombok ou MapStruct dans certaines configurations, fonctionnent mal avec Kotlin. Dans ce cas, vous pouvez introduire Kotlin progressivement : écrire des tests ou de nouveaux modules en Kotlin tout en conservant l’ancien code Java.
10. Conclusion
Vous avez construit un backend Spring Boot 3 en Kotlin, comprenant :
- configuration de
kotlin-maven-pluginavecspring,jpa,all-openet-Xjsr305=strict; - entités JPA utilisant une classe ordinaire, DTO utilisant
data classavec@field:; - services et controllers tirant parti de la sécurité null, de
findByIdOrNullet de l’opérateur Elvis ; - configuration immuable avec
@ConfigurationPropertiessurdata class; - tests avec JUnit 5 et AssertJ, nommés avec des phrases descriptives entre backticks.
La plupart des connaissances Spring que vous avez restent applicables. Ce qu’il faut retenir, ce sont trois différences lors du passage à Kotlin : les classes sont final par défaut, les annotations nécessitent une cible de site d’utilisation, et les types nullables doivent être déclarés explicitement. En maîtrisant ces trois points, vous évitez la plupart des erreurs difficiles à trouver lors de la migration de Java à Kotlin.