Kotlin et Java dans un même projet Spring Boot avec Maven

1. Vue d’ensemble

Beaucoup d’équipes veulent utiliser Kotlin mais ne peuvent pas réécrire toute la base de code Java. Heureusement, Kotlin s’exécute sur la JVM, donc les deux langages peuvent coexister dans le même module et s’appeler directement.

Dans cet article, vous allez configurer Maven pour compiler à la fois Kotlin et Java. Ensuite, vous gérerez la nullabilité quand Kotlin appelle du Java et utiliserez les annotations @JvmStatic, @JvmOverloads, @JvmName pour que Java appelle du Kotlin plus facilement. À la fin, je vous montrerai comment l’intégrer à Spring Boot 3.x et les erreurs courantes.

2. Configuration Maven

2.1. Dépendances

Le projet utilise spring-boot-starter-parent 3.x et Java 21. Vous ajoutez la bibliothèque standard de Kotlin et kotlin-reflect. Spring a besoin de kotlin-reflect pour lire les paramètres du constructeur et les valeurs par défaut des classes Kotlin.

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

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

Spring Boot gère déjà les versions des artefacts Kotlin. La propriété kotlin.version est utilisée uniquement pour surcharger la version par défaut.

2.2. Ordre de compilation

C’est la partie la plus importante. Kotlin doit être compilé avant Java. Le compilateur Kotlin comprend le code source Java. Il lit les fichiers .java pour résoudre les types, mais ne compile pas ces fichiers. En revanche, javac ne peut pas lire les fichiers .kt du tout. Java ne voit une classe Kotlin que lorsque les fichiers .class de Kotlin sont déjà générés.

<build>
    <plugins>
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <configuration>
                <jvmTarget>21</jvmTarget>
                <args>
                    <arg>-Xjsr305=strict</arg>
                </args>
                <compilerPlugins>
                    <plugin>spring</plugin>
                </compilerPlugins>
            </configuration>
            <dependencies>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-allopen</artifactId>
                    <version>${kotlin.version}</version>
                </dependency>
            </dependencies>
            <executions>
                <execution>
                    <id>compile</id>
                    <goals><goal>compile</goal></goals>
                    <configuration>
                        <sourceDirs>
                            <sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
                            <sourceDir>${project.basedir}/src/main/java</sourceDir>
                        </sourceDirs>
                    </configuration>
                </execution>
                <execution>
                    <id>test-compile</id>
                    <goals><goal>test-compile</goal></goals>
                    <configuration>
                        <sourceDirs>
                            <sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
                            <sourceDir>${project.basedir}/src/test/java</sourceDir>
                        </sourceDirs>
                    </configuration>
                </execution>
            </executions>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <executions>
                <execution>
                    <id>default-compile</id>
                    <phase>none</phase>
                </execution>
                <execution>
                    <id>default-testCompile</id>
                    <phase>none</phase>
                </execution>
                <execution>
                    <id>java-compile</id>
                    <phase>compile</phase>
                    <goals><goal>compile</goal></goals>
                </execution>
                <execution>
                    <id>java-test-compile</id>
                    <phase>test-compile</phase>
                    <goals><goal>testCompile</goal></goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Quelques points à noter dans la configuration ci-dessus :

  • sourceDirs déclare aussi le répertoire java. Cela permet au compilateur Kotlin de lire les classes Java quand Kotlin appelle du Java.
  • Les deux exécutions default-compile et default-testCompile sont désactivées par <phase>none</phase>. Ensuite, elles sont redéclarées avec de nouveaux ID. Maven exécute les plugins de la même phase dans l’ordre de leur déclaration, donc javac s’exécutera après Kotlin.
  • -Xjsr305=strict active la vérification stricte de nullabilité pour les annotations de Spring (voir section 3).
  • <plugin>spring</plugin> est le plugin all-open pour Spring (voir section 5).

La structure des répertoires ressemblera à ceci :

src/main/java/com/nonla/shop/Customer.java
src/main/java/com/nonla/shop/CheckoutController.java
src/main/kotlin/com/nonla/shop/Order.kt
src/main/kotlin/com/nonla/shop/PaymentService.kt
src/test/java/com/nonla/shop/OrderPricingTest.java

3. Appeler du Java depuis Kotlin

3.1. Les getters deviennent des propriétés

Supposons que vous ayez une ancienne classe Java :

package com.nonla.shop;

import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;

public class Customer {

    private final String name;
    private final String email;

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
    }

    @NonNull
    public String getName() {
        return name;
    }

    @Nullable
    public String getEmail() {
        return email;
    }
}

Depuis Kotlin, les paires getX()/setX() sont accessibles comme des propriétés :

package com.nonla.shop

fun greeting(customer: Customer): String {
    val contact = customer.email ?: "chưa có email"
    return "Xin chào ${customer.name} ($contact)"
}

Kotlin appelle customer.name mais en réalité appelle getName(). Avec Customer("Lan", null), la fonction retourne :

Xin chào Lan (chưa có email)

3.2. Platform type et nullabilité

Java n’a pas le concept de nullable dans son système de types. Par conséquent, quand Kotlin rencontre un type Java sans annotation, il le traite comme un platform type, écrit comme String!. Avec un platform type, le compilateur ne capture pas les erreurs. Si la valeur est null et que vous l’utilisez comme non-null, le programme lèvera une NullPointerException à l’exécution.

@Nullable et @NonNull de Spring sont meta-annotés avec JSR-305. Quand vous activez -Xjsr305=strict, Kotlin comprend que getEmail() retourne String?. Le code suivant ne compilera pas :

val length = customer.email.length // erreur: Only safe (?.) or non-null asserted (!!.) calls are allowed

Vous le corrigez avec customer.email?.length ?: 0.

Remarque : Vous ne voulez pas annoter chaque méthode ? Mettez @NonNullApi de Spring dans package-info.java. Cette annotation traite tous les paramètres et valeurs de retour du package comme non-null, sauf s’ils sont marqués @Nullable.

4. Appeler du Kotlin depuis Java

Kotlin a de nombreuses fonctionnalités que Java n’a pas : les fonctions top-level, les paramètres par défaut, les companion objects. Certaines annotations aident à rendre l’API Kotlin plus « naturelle » quand on l’appelle depuis Java.

4.1. Data class et fonctions top-level

@file:JvmName("OrderPricing")
package com.nonla.shop

data class Order @JvmOverloads constructor(
    val id: String,
    val amount: Long,
    val couponCode: String? = null
)

@JvmOverloads
fun totalWithShipping(
    order: Order,
    shippingFee: Long = 30_000,
    freeShipThreshold: Long = 500_000
): Long =
    if (order.amount >= freeShipThreshold) order.amount
    else order.amount + shippingFee

Chaque annotation a son propre rôle :

  • @file:JvmName("OrderPricing") : Par défaut, les fonctions top-level dans Order.kt sont mises dans une classe OrderKt. Cette annotation change le nom de cette classe en OrderPricing.
  • @JvmOverloads sur le constructeur : Java ne comprend pas les paramètres par défaut. Cette annotation génère un surcharge supplémentaire Order(String, long), permettant à Java d’éviter de passer null pour couponCode.
  • @JvmOverloads sur la fonction : génère trois surcharges, prenant respectivement 1, 2 et 3 paramètres.

Le code Java appelle du Kotlin comme suit :

var order = new Order("ORD-1", 200_000L);
long total = OrderPricing.totalWithShipping(order);
System.out.println(order.getAmount() + " -> " + total);
System.out.println(order);
200000 -> 230000
Order(id=ORD-1, amount=200000, couponCode=null)

La propriété val amount de Kotlin apparaît en Java sous la forme de getAmount(). Les méthodes toString(), equals(), hashCode() de la data class fonctionnent normalement. Notez que Order est immutable : Java ne possède que des getters, pas de setters.

4.2. Companion object et @JvmStatic

package com.nonla.shop

import java.util.concurrent.atomic.AtomicLong

class OrderIdGenerator {
    companion object {
        const val PREFIX = "ORD-"
        private val counter = AtomicLong()

        @JvmStatic
        fun next(): String = PREFIX + counter.incrementAndGet()
    }
}

Depuis Java :

String id = OrderIdGenerator.next();      // "ORD-1"
String prefix = OrderIdGenerator.PREFIX;  // constante static grâce à const

Sans @JvmStatic, Java doit appeler OrderIdGenerator.Companion.next(). Cette façon d’appeler fonctionne mais elle est verbeux et expose les détails d’implémentation. Pour une propriété qui n’est pas const, utilisez @JvmField pour l’exposer comme un champ public static.

4.3. Checked exceptions et @Throws

Kotlin n’a pas de checked exceptions. Si une fonction Kotlin lève une IOException que vous n’avez pas déclarée, Java ne pourra pas faire catch (IOException e) car javac signalera que cette exception n’est jamais levée. Vous utilisez @Throws pour le résoudre :

@Throws(java.io.IOException::class)
fun loadInvoice(path: String): String = java.io.File(path).readText()

5. Intégration avec Spring Boot

Les classes et méthodes en Kotlin sont final par défaut. Pendant ce temps, Spring crée des proxies (CGLIB) pour les beans avec @Transactional, @Async, @Cacheable ou @Configuration. Les proxies CGLIB ont besoin de pouvoir étendre la classe d’origine. Le plugin spring que vous avez déclaré à la section 2 rend automatiquement open toutes les classes marquées @Component, @Service, @Transactional… ainsi que leurs méthodes.

package com.nonla.shop

import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class PaymentService {

    @Transactional
    fun pay(order: Order, customer: Customer): String {
        val total = totalWithShipping(order)
        return "Đã thu $total VND của ${customer.name} cho ${order.id}"
    }
}

Un contrôleur Java injecte ce bean Kotlin normalement via le constructeur :

package com.nonla.shop;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CheckoutController {

    private final PaymentService paymentService;

    public CheckoutController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/checkout")
    public String checkout() {
        var order = new Order(OrderIdGenerator.next(), 600_000L);
        return paymentService.pay(order, new Customer("Lan", null));
    }
}

Le résultat quand vous appelez POST /checkout :

Đã thu 600000 VND của Lan cho ORD-1

La commande dépasse le seuil de 500 000, donc la livraison est gratuite.

Sans le plugin spring, l’application ne pourra pas démarrer si vous avez une classe @Configuration écrite en Kotlin. Pour les autres beans, @Transactional peut être ignoré sans aucun avertissement.

6. Écrire des tests

Écrire des tests en Java qui appellent du code Kotlin est un bon moyen de vérifier si l’API Kotlin est conviviale pour Java. Si les tests sont difficiles à écrire, vos utilisateurs Java aussi auront du mal.

package com.nonla.shop;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class OrderPricingTest {

    @Test
    void addsDefaultShippingFeeBelowThreshold() {
        var order = new Order("ORD-1", 200_000L);

        assertThat(OrderPricing.totalWithShipping(order)).isEqualTo(230_000L);
    }

    @Test
    void freeShippingWhenAmountReachesThreshold() {
        var order = new Order("ORD-2", 500_000L);

        assertThat(OrderPricing.totalWithShipping(order)).isEqualTo(500_000L);
    }

    @Test
    void customShippingFeeViaOverload() {
        var order = new Order("ORD-3", 100_000L, "TET2026");

        assertThat(OrderPricing.totalWithShipping(order, 15_000L)).isEqualTo(115_000L);
        assertThat(order.getCouponCode()).isEqualTo("TET2026");
    }

    @Test
    void dataClassEqualityWorksFromJava() {
        assertThat(new Order("ORD-4", 1L)).isEqualTo(new Order("ORD-4", 1L));
    }
}

Exécutez mvn test. Dans les journaux, vous verrez Kotlin être compilé avant Java :

[INFO] --- kotlin:2.1.0:compile (compile) @ shop ---
[INFO] --- compiler:3.13.0:compile (java-compile) @ shop ---
[INFO] --- kotlin:2.1.0:test-compile (test-compile) @ shop ---
[INFO] --- compiler:3.13.0:testCompile (java-test-compile) @ shop ---
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

7. Erreurs courantes

Java rapporte cannot find symbol pour une classe Kotlin. La cause est presque toujours que javac s’exécute avant le compilateur Kotlin. Vérifiez de nouveau que vous avez désactivé default-compile et l’ordre de déclaration des plugins à la section 2.2.

NullPointerException quand Kotlin appelle du Java. C’est la conséquence des platform types. Vous devez ajouter @Nullable/@NonNull (ou @NonNullApi) à votre code Java et activer -Xjsr305=strict. Une autre façon est de déclarer explicitement le type à la limite, par exemple val email: String? = customer.email.

Kotlin ne voit pas les getters générés par Lombok. Le compilateur Kotlin lit le code source Java avant que Lombok ne s’exécute. Par conséquent, il ne sait pas quelles méthodes @Getter ou @Data vont générer. Vous pouvez ajouter le plugin compilateur lombok pour Kotlin (artefact kotlin-maven-lombok). Une approche plus épurée est de convertir les classes Lombok que Kotlin doit utiliser en Java records ou Kotlin data classes.

JPA entity écrit en Kotlin. Hibernate a besoin que l’entity ait un constructeur sans paramètres et ne soit pas final. Vous ajoutez le plugin jpa (no-arg, artefact kotlin-maven-noarg) à côté du plugin spring. De plus, vous ne devez pas utiliser data class pour une entité, car equals/hashCode générés automatiquement causeront des problèmes avec le lazy loading.

Remarque : Quand ne faut-il pas mélanger les deux langages ? Si l’équipe n’a qu’une ou deux personnes qui connaissent Kotlin, le coût de la révision et de la maintenance augmentera. Le temps de construction est aussi plus lent car il y a deux compilateurs. Une autre option est de séparer Kotlin dans son propre module Maven. La limite entre les deux langages sera alors claire, et vous éviterez les dépendances circulaires entre les fichiers .kt et .java.

Une règle pratique : commencez par du code Kotlin avec peu de dépendances, comme les DTOs, les fonctions utilitaires et les tests. Ne commencez pas par réécrire la logique métier centrale.

8. Conclusion

Kotlin et Java peuvent coexister dans le même module Maven. Vous n’avez besoin que d’une condition : le compilateur Kotlin doit s’exécuter avant javac et pouvoir lire les deux répertoires de code source. Quand Kotlin appelle du Java, faites attention aux platform types et utilisez les annotations de nullabilité avec -Xjsr305=strict. Quand Java appelle du Kotlin, @JvmName, @JvmOverloads, @JvmStatic et @Throws rendent l’API beaucoup plus facile à utiliser. Avec Spring Boot, le plugin spring (all-open) est obligatoire pour que les proxies fonctionnent correctement. Enfin, écrire des tests en Java pour le code Kotlin est le moyen le plus rapide de savoir si votre API est vraiment conviviale pour Java.

kotlinjavaspring-bootmaveninterop