Wer ein neues Projekt auf der JVM aufsetzt, steht vor einer grundlegenden Entscheidung: Java oder Kotlin? Wofür sollte man sich entscheiden, wenn die Fachlichkeit viele Regeln und Feinheiten mitbringt, die möglichst eindeutig ausgedrückt werden müssen? Kurz: Was, wenn eine reichhaltige Domäne modelliert werden soll?

In Teil 1 dieser Serie wurden die Ökosysteme beider Sprachen verglichen, Teil 2 beschäftigte sich mit den Multiplattform-Fähigkeiten, Teil 3 lieferte eine allgemeine Einführung in die Kotlin-Syntax und ihre Interoperabilität mit der Java-Plattform, und Teil 4 widmete sich den Sicherheits-Features beider Sprachen. In diesem Teil geht es darum, wie gut die Sprachen beim Modellieren von Geschäftsdomänen unterstützen, bevor zum Schluss ein Urteil fällig wird: Java oder Kotlin?

Bewertungskriterien für Sprachen

Programmiersprachen, die sich gut zum Modellieren von Geschäftsdomänen eignen, erfüllen im Idealfall die folgenden Kriterien:

  1. Code liest sich wie fachliche Prosa
  2. Sicherheit zur Kompilierungszeit
  3. Interoperabilität mit der Plattform
  4. Unveränderlichkeit
  5. Prägnanz

Das ist ungefähr meine Prioritätenreihenfolge. Ausdrucksstarke Prosa ist mir zum Beispiel wichtiger als Nullsicherheit. Der erste Punkt, Code liest sich wie fachliche Prosa, ist zentral für die Domänenmodellierung und steht in diesem Teil im Mittelpunkt. Die übrigen Punkte bekommen keinen eigenen Abschnitt, denn Sicherheit zur Kompilierungszeit war das Thema von Teil 4, Interoperabilität und Unveränderlichkeit das von Teil 3. Gestreift werden diese Kriterien aber in den folgenden Codebeispielen, die zugleich das letzte Kriterium zeigen: wie prägnant (engl. concise) die Sprachen sind.

Das nächste Kapitel zeigt, wie sich eine Domäne mit beiden Sprachen modellieren lässt und wie nah das an echte fachliche Prosa herankommt. Danach geht es endlich um die Frage, die diese Artikelserie seit dem Anfang verspricht: Kotlin oder Java? Wer mit der Kotlin-Syntax nicht vertraut ist, sollte vorher noch einmal Teil 3 lesen, denn die folgenden Kapitel bauen alle auf dem auf, was dort gezeigt wurde.

Als Beispieldomäne für diesen Teil dient Martin Fowlers Videothek. Das ist ein Geschäft, in dem man etwas namens „DVD“ ausleihen konnte – eine glänzende Scheibe, auf der Videos aller Art gespeichert waren. Diese Scheiben mussten rechtzeitig zurückgegeben werden, damit keine „Verleihgebühren“ anfielen. Die Gebühr für die Ausleihe variierte je nach Art des ausgeliehenen Videos. Es war die Zeit von „Blockbuster“, vor langer Zeit, bevor man alle Inhalte streamen konnte.

Code liest sich wie fachliche Prosa

Code wird häufiger gelesen beziehungsweise geparst als geschrieben beziehungsweise erzeugt. Je klarer sich die fachliche Absicht ausdrücken lässt, desto weniger kann sich in Mehrdeutigkeit verstecken und desto leichter lässt sich der Code ändern. Und genau darum geht es. „Die Qualität von Software bemisst sich an unserer Fähigkeit, sie zu verändern“ (so Dave Farley). Code, der sich wie fachliche Prosa liest, leistet dabei doppelte Arbeit: Er ist nicht nur ausführbar, sondern zugleich Dokumentation, und braucht weniger Kontext, um verstanden zu werden. Programmiersprachen können hier unterstützen, aber welche macht das besser? Der folgende Code versucht, das an fünf einfachen, aber typischen Situationen der Geschäftslogik zu zeigen: eine Fallunterscheidung modellieren, verschachtelte nulls behandeln, eine Fallunterscheidung treffen, über Daten iterieren und natürlich testen, ob das Verhalten korrekt ist.

Allgemeine Anmerkung: Die folgenden Codebeispiele bevorzugen starke Typen. Ein Tag wird als JDay/KDay modelliert, nicht als int/Int. Dieser Stil stärkt die Semantik des Codes und verhindert zugleich unbeabsichtigte Fehler, denn inkompatible Typen können nicht miteinander interagieren. Die Beispiele legen die Geschäftslogik außerdem außerhalb der records und data classes ab, in Anlehnung an einen datenorientierten beziehungsweise funktionalen Stil. Dieser Stil scheint heute recht beliebt zu sein, und Java hat ihn seit JDK 17 übernommen. Das Ganze mit einem reichhaltigen Domänenmodell beziehungsweise objektorientiert zu schreiben, wo Daten verborgen sind und Verhalten direkt bei den Daten liegt, auf denen es arbeitet, sei den Neugierigen als Aufgabe überlassen.

Eine Fallunterscheidung modellieren

Eine Situation, in der Java sich mit JDK 16 und 17 wirklich verbessert hat, ist das Modellieren von Fallunterscheidungen. In der Videothek-Domäne gibt es drei Arten von Ausleihen, die modelliert werden müssen: Neuerscheinungen, Kinderfilme und reguläre Ausleihen. Das lässt sich mit Sealed Interfaces abbilden:

sealed interface JRental { // <1> 
    @Nullable JReturnPolicy returnPolicy(); // <2>
    JDay rentedDays(); // <3>
}

record JNewRental( // <4>
    JRentalId id, JDay rentedDays
) implements JRental {  
  
    @Override  
    public JReturnPolicy returnPolicy(){  
        return new JReturnPolicy(  
            new JDay(2) // <5> mustBeReturnedAfter field
        );  
    }  
  
}
// JRegularRental and JChildrensRental omitted for brevity\

JRental <1> ist ein Interface, für das genau drei Implementierungen existieren: JNewRental <4>, JRegularRental und JChildrensRental. Es lassen sich aber noch mehr Fachregeln explizit machen. Eine Ausleihe kann eine Rückgaberegel haben, muss es aber nicht, und diese Optionalität ist explizit, weil sie @Nullable <2> ist. Eine Neuerscheinung soll nach zwei Tagen zurückgegeben werden <5>. Jede Ausleihe weiß, wie lange sie schon läuft <3>. Dasselbe lässt sich in Kotlin mit sehr ähnlicher Syntax modellieren:

sealed interface KRental { // <1>  
    val returnPolicy: KReturnPolicy? // <2>
    val rentedDays: KDay // <3>
}

data class KNewRental(
    val id: KRentalId, override val rentedDays: KDay
) : KRental { // <4>
    override val returnPolicy = KReturnPolicy(  
        mustBeReturnedAfter = 2.days() // <5>
    )  
}\

Der große Unterschied: Nullbarkeit <2> ist in Kotlin kompakter, Vererbung wird nicht mit einem Schlüsselwort, sondern mit einem Doppelpunkt geschrieben <4>, und Kotlin hat benannte Parameter, mit denen sich die Absicht hier expliziter ausdrücken lässt <5> und der Code auch ohne die Parameter-Hinweise einer IDE lesbarer wird.

Damit ist das Modellieren von Fallunterscheidungen abgehandelt. Als Nächstes geht es darum, wie sich verschachtelte Nulls behandeln lassen.

Verschachtelte Nulls behandeln

In Java lässt sich die Rückgaberegel nun nutzen, um die lateFee für eine Ausleihe zu berechnen:

class JRentalService {
    // other code omitted for brevity
    private JMoney lateFee(JRental rental) {  
        var policy = rental.returnPolicy();  
        if(policy == null || policy.mustBeReturnedAfter() == null){ // <0> 
            return JMoney.ofEuros(0); // <1>
        }  
      
        return JMoney.ofEuros( // <2>
            rental.rentedDays().minus(policy.mustBeReturnedAfter())
            .atLeastZero().times(3)
            .rawValue() // <3>
        );  
    }
}\

Die Geschäftslogik ist hier eigentlich recht simpel: Wer die Ausleihe länger behält, als die Regel erlaubt <3>, zahlt eine Verzugsgebühr in Euro <2>. <1> ist nur ein early return für den Fall, dass entweder returnPolicy oder mustBeReturnedAfter null ist. Im Beispiel wurden JDay die Methoden minus, times und atLeastZero hinzugefügt, um im Typ zu bleiben. Eine Konvertierungsmethode .toEuros() bekam JDay dagegen nicht, weil Tage und Euro semantisch nicht zusammenhängen und daher nicht voneinander abhängen sollten. Zwingend sind diese Methoden allerdings nicht. Mit rawValue() kommt man auch an den zugrunde liegenden int heran, der in JDay gespeichert ist, und kann den return mit Operatoren (-, *) umschreiben, wie im Code unten zu sehen. Ich persönlich finde die folgenden Operatoren leichter lesbar, mag aber den Verlust an Typsicherheit und die zusätzliche Verschachtelung von Math.max nicht, weshalb ich beim ersten Beispiel bleibe.

       return JMoney.ofEuros( // <2>  
            Math.max(rental.rentedDays().rawValue() - policy.mustBeReturnedAfter().rawValue(), 0) * 3 // <3>  
        );\

Wer genau hinschaut, hat vielleicht bemerkt, dass der vorige Code in stark nebenläufigen Codebasen einen Bug hat. Wie in Teil 4 besprochen, nimmt NullAway an, dass Methoden wie returnPolicy funktional pur sind, also bei gleichen Parametern auf derselben Instanz immer denselben Wert zurückgeben. Würde ein anderer Thread mustBeReturnedAfter zwischen den beiden Null-Prüfungen <0> und der Subtraktion <3> verändern, würde dieser Code sauber kompilieren, könnte zur Laufzeit aber eine NullPointerException werfen. Ob das ein echtes Problem ist, hängt von der Nebenläufigkeit und der Veränderlichkeit der Daten ab. Stark nebenläufig und unveränderlich würde zum Beispiel problemlos funktionieren.

In Kotlin lässt sich die Logik so schreiben:

class KRentalService {
    // other code omitted for brevity
    private fun lateFee(rental: KRental): KMoney {  
        val mustBeReturnedAfter: KDay = rental.returnPolicy?.mustBeReturnedAfter 
            ?: return 0.euros() // <1>
            
        return ((rental.rentedDays - mustBeReturnedAfter)
                .atLeastZero() * 3) // <3>
                .toEuros() // <2>
    }
}\

Es gibt denselben frühen Rücksprung, wenn returnPolicy oder mustBeReturnedAfter null ist <1>. Mit den Safe Calls ?. wird ermittelt, ob mustBeReturnedAfter null ist, und falls ja, wird der Ausdruck rechts vom Elvis-Operator ?: ausgeführt, der prompt zurückspringt. Springt er nicht zurück, geht es weiter mit der Geschäftslogik <3>. Zu beachten ist, dass für die Subtraktion nicht rawValue verwendet wird, sondern direkt KDay-Objekte, was möglich ist, weil der minus-Operator überladen wurde. Danach wird das Ganze toEuros konvertiert <2>, und zwar mit einer Extension Function (diese lebt außerhalb von KDay und KMoney und vermeidet so direkte Abhängigkeiten zwischen den beiden Klassen). Die Reihenfolge — erst Geschäftslogik, dann Konvertierung — entspricht der natürlichen Leserichtung, aber auch dem Ablauf zur Laufzeit, ein netter Vorteil des Kotlin-Ansatzes. Weitere Vorteile zeigen sich, wenn es darum geht, eine Fallunterscheidung zu treffen.

Eine Fallunterscheidung treffen

Nachdem die Verzugsgebühr berechnet ist, lässt sich nun der Preis einer rental für einen bestimmten renter bestimmen:

class JRentalService {
    // other code omitted for brevity
    private JMoney price(JRental rental, JRenter renter) {  
        var freeDays = renter.isRecentSignup() && renter.freeFirstDays() != null
            ? renter.freeFirstDays().rawValue()  
            : 0; 
      
        int chargedDays = switch (rental) { // <0>
            case JRegularRental regular -> regular.rentedDays().rawValue() - freeDays;  // <1>
            case JNewRental(_, JDay rentedDays) -> rentedDays.times(2).rawValue(); // <2>
            case JChildrensRental(_, JDay(int rawDays)) -> rawDays / 2; // <3>
        };  
      
        return JMoney.ofEuros(Math.max(chargedDays, 0));  
    }
}\

Hier ist eine Fallunterscheidung nötig <0>. Das Typsystem weiß, dass JRental nur drei Implementierungen hat, und damit auch, dass nur diese Fälle behandelt werden müssen. Die folgenden case-Ausdrücke nutzen jeweils einen anderen Ansatz beim Pattern Matching (Pattern Matching seit JDK 21 | September 2023; _ für unbenannte Variablen und Patterns seit JDK 22 | März 2024), damit die Modellierungsalternativen sichtbar werden.

  1. Der erste Fall matcht auf Typen und holt sich den rawValue per Method Chaining.
  2. Der zweite Fall matcht eine Ebene tiefer auf rentedDays und multipliziert mit der Funktion times.
  3. Der dritte Fall matcht auf das innerste Pattern, rawDays, und kann danach mit primitiver Division arbeiten.

Welche dieser Varianten idiomatisches Java ist, ist mir nicht klar. Pattern Matching ist „erst“ drei Jahre alt, und Record Patterns sind auf Records beschränkt, was die Verbreitung etwas dämpft. Alle drei Optionen stehen hier, weil sie alle idiomatisches Java sein könnten, was sie zum Vergleichsobjekt macht. In Kotlin würde derselbe Code so aussehen:

class KRentalService {
    // other code omitted for brevity
    private fun price(rental: KRental, renter: KRenter): KMoney {  
        val freeDays = if (renter.isRecentSignup && renter.freeFirstDays != null)  
            renter.freeFirstDays  
            else 0.days() 
      
        val chargedDays = when (rental) { // <0>
            is KRegularRental -> rental.rentedDays - freeDays // <1>  
            is KNewRental -> rental.rentedDays * 2 // <2>
            is KChildrensRental -> rental.rentedDays / 2 // <3> 
        }.atLeastZero()  
      
        return chargedDays.toEuros()  
    }
}\

In Kotlin lässt sich dieselbe Fallunterscheidung treffen, und wie in Java weiß der Compiler, dass alle Fälle behandelt wurden. Das geschieht mit einem when-Ausdruck <0>, der Javas switch ersetzt hat. When ist etwas mächtiger, weil es auch ohne Argument funktioniert, womit sich längere if-else-Ketten knapp schreiben lassen.

Danach wird mit is geprüft, welcher Fall gerade behandelt wird <1>, <2>, <3>. is ist Kotlins Variante des Schlüsselworts instanceof. Passt ein Typ, wird rental automatisch in diesen Typ gecastet. Im Fall <1> ist rental rechts vom Pfeil -> zum Beispiel vom Typ KRegularRental. Das nennt sich Smart Casting. Es ist weniger mächtig als Pattern Matching in Java, das eine Kombination aus Typprüfung, Typcast und Zerlegung des Objekts in seine Bestandteile ist, denn hier gibt es nur die ersten beiden Fähigkeiten. Dafür ist es weniger geschwätzig, weil sich neue Variablennamen sparen lassen, wie die Java-Fälle zeigen. Insgesamt wirkt der Kotlin-Code etwas aufgeräumter und weniger unruhig, dank Smart Casts, der überladenen Operatoren und der typerzeugenden Extension-Methoden days() und toEuros().

Mit den Methoden price und lateFee im Gepäck geht es nun darum, sie beim Iterieren über Daten einzusetzen.

Über Daten iterieren

Im nächsten Codebeispiel werden alle rentals eines renter durchlaufen, um zu sehen, wie viel er derzeit schuldet:

class JRentalService {  
    private final Map<JRenterId, List<JRental>> rentals;  
  
    public JRentalService(Map<JRenterId, List<JRental>> rentals) {  
        this.rentals = rentals;  
    }  
  
    public JMoney amountOwed(JRenter renter) {  
        return rentals.getOrDefault(renter.id(), List.of()) // <1>  
            .stream()  
            .map(rental -> price(rental, renter).plus(lateFee(rental))) // <2>
            .reduce(JMoney.ofEuros(0), JMoney::plus); // <3>
    }
    // other code omitted for brevity
}\

Zuerst werden alle rentals des renter geholt oder ein leeres List.of() <1>. Dann werden price und lateFee für jede rental berechnet <2>. Zum Schluss wird alles mit der Methode plus des Typs JMoney aufsummiert <3>. In Kotlin sieht das ähnlich aus:

class KRentalService(private val rentals: Map<KRenterId, List<KRental>>) { 

    fun amountOwed(renter: KRenter): KMoney = // <0>
        rentals[renter.id].orEmpty() // <1> 
            .map { rental -> price(rental, renter) + lateFee(rental) } // <2>
            .sum() // <3>
            
    // other code omitted for brevity
}\

In Kotlin spart man ein paar Zeilen, weil es Expression Bodies gibt <0>, die Funktionen mit einem einzigen return verkürzen. Auf die rentals des renter wird mit dem überladenen Index-Operator [] zugegriffen, einer Map<>-Operation, die null zurückgeben kann, und per Extension Function deklariert, dass die Vorgabe im null-Fall eine leere Liste ist <1>. Die Mapping-Funktion <2> ist meiner Ansicht nach besser lesbar, dank Kotlins Aufrufsyntax [map { -> } gegenüber map(() -> )] und dem Operator Overloading. Aufsummiert wird über die Extension Function sum <3>, wobei hier auch reduce möglich gewesen wäre, je nachdem, was einem vertrauter ist.

Nachdem die Geschäftslogik steht, folgt noch ein Blick darauf, wie sich die Korrektheit des Verhaltens testen lässt.

Korrektheit testen

Tests sollten sich wie fachliche Prosa lesen. Sie sollten dieselbe Aufmerksamkeit bekommen wie Produktionscode, vermutlich sogar mehr, denn sie schützen und dokumentieren das beabsichtigte Verhalten. Es gibt einige Wege, auf denen Programmiersprachen dabei unterstützen können — über das hinaus, was das Test-Framework mitbringt. In Java sähe ein einfacher Test der Geschäftslogik mit JUnit so aus:

@Test
void regular_rentals_should_cost_one_euro_per_rented_day() { // <1>  
    var renter = aRenter().build(); // <2>  
    var rental = aRegularRental().rentedFor(new JDay(3)).build(); // <3>  
    var testee = new JRentalService( Map.of(renter.id(), List.of(rental)) );  
  
    var result = testee.amountOwed(renter);  
  
    assertThat(result).isEqualTo(JMoney.ofEuros(3)); // <4>  
}\

Dank der JUnit-Property junit.jupiter.displayname.generator.default, die auf org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores gesetzt wird, erscheint der Testname <1> als regular rentals should cost one euro per rented day. Zwei Builder helfen dabei, zu definieren, was für den Test wichtig ist: Er braucht aRenter <2> und aRegularRental, die für drei Tage ausgeliehen wurde <3>. Die Builder helfen auch deshalb, weil sich irrelevante Details weglassen lassen, etwa der Name des Kunden. Danach lässt sich das Ergebnis prüfen <4>. In Kotlin sähe derselbe Test, ebenfalls mit JUnit, so aus:

@Test
fun `regular rentals should cost one euro per rented day`() { // <1>
   val renter = aRenter { } // <2>
   val rental = aRegularRental { rentedFor = 3.days() } // <3>
   val service = KRentalService( mapOf(renter.id to listOf(rental)) )
   
   val result = service.amountOwed(renter)

   result.shouldBe(3.euros()) // <4>
}\

In Kotlin lassen sich alle Funktionsnamen mit Leerzeichen zwischen Backticks schreiben. In Produktionscode ist das nur in den seltensten Fällen nützlich. In Testcode dagegen ist es ausgesprochen praktisch und sogar Teil von Kotlins offiziellen Coding Conventions. Man kann Sätze mit Leerzeichen schreiben, und die lesen sich ausgesprochen natürlich <1>, ganz ohne JUnit-DisplayNameGenerator.

Wie funktioniert das auf der JVM? Kurioserweise unterstützt Bytecode Leerzeichen in Methodennamen.

Für den Aufbau der Testdaten lassen sich die typsicheren Builder nutzen (siehe auch Teil 4), was das Ganze gut lesbar macht <2, 3>, und auch Extension-Methoden kommen wirkungsvoll zum Einsatz. shouldBe ist eine eingebaute Extension-Methode des Test-Frameworks, die es auf allen Typen gibt, um auf Korrektheit zu prüfen <4>. Eigene Vergleichsmethoden lassen sich ebenfalls leicht ergänzen, wenn eine fachspezifische Methode gebraucht wird, denn Extension-Methoden sind ein Sprachfeature, das jeder frei nutzen kann. Wer schon einmal versucht hat, AssertJ eine eigene Vergleichsmethode hinzuzufügen, weiß, dass das in Java nicht ganz so einfach ist.

Kotlin kontra Java, die Zusammenfassung

Wer ein neues Projekt auf der JVM aufsetzt: Java oder Kotlin? Für die Modellierung von Geschäftslogik ergibt sich folgende Tabelle:

KriteriumJavaKotlinKommentar
Code liest sich wie fachliche Prosa●●○●●●Java hat mit Records, Sealed Types und Pattern Matching mächtige Modellierungsmittel. Kotlin hat, je nach Sichtweise, besser lesbare Smart Casts, eingebautes Operator Overloading (==, in, is), Extension Functions, die Modellierung in Leserichtung erlauben, und ausdrucksstarke typsichere Builder.
Sicherheit zur Kompilierungszeit●●○●●◐Speichersicherheit liefert die JVM, Thread-Sicherheit liegt in beiden Sprachen überwiegend beim Entwickler. In Java lässt sich Nullsicherheit über ein Compiler-Plugin und Sorgfalt erreichen. Kotlin ist von Anfang an auf Nullsicherheit ausgelegt, hat stärkere Laufzeitgarantien, und die Arbeit mit nullbaren Typen fühlt sich ergonomischer an. Custom String Templates hat keine der beiden Sprachen.
Interoperabilität mit der Plattform●●●●●◐Java hat perfekte Interoperabilität mit der Plattform, weil es dafür entworfen wurde. Kotlin ist mit Blick auf Interoperabilität entworfen, aber es gibt einige Fälle, in denen Anwendungsentwickler einen gewissen Komfortverlust bemerken, und mehr Fälle, in denen Bibliotheksentwickler aufpassen müssen.
Unveränderlichkeit●●○●●◐Java hat mit Records große Fortschritte in Richtung Unveränderlichkeit gemacht, seine unveränderlichen Collections bieten aber weiterhin keine Typsicherheit. In Kotlin ist eine unveränderliche Collection typsicher, weil ihr Interface keine mutierenden Operationen enthält. Andere Sprachen (z. B. Clojure) bieten allerdings noch bessere Unveränderlichkeit.
Prägnanz●●○●●●Records, Sealed Types, Switch Expressions und Pattern Matching haben Java zu einer deutlich prägnanteren Sprache gemacht. Kotlin ist immer noch kompakter — Konstruktoren mit eingebauter Feldzuweisung, vielseitige Erweiterungsfunktionen der Standardbibliothek wie .let{} und Expression Bodies für Methoden helfen hier — aber der Abstand ist deutlich kleiner geworden.

Dass Code sich wie fachliche Prosa lesen sollte, ist wohl das wichtigste Kriterium für eine Sprache, die auf das Ausdrücken von Geschäftslogik zielt. Beide Sprachen schneiden hier gut ab. Die Sprachfeatures, die Java verfolgt hat (Records, Sealed Types und Switch Expressions mit JDK 17; Record Patterns mit JDK 21), haben sich klar ausgezahlt. Kotlin liegt bei der Ausdrucksstärke der Sprache weiter vorn, aber es zeichnet sich einiges ab, das die Bilanz in den nächsten fünf Jahren verändern wird.

Kotlin oder Java?

Java oder Kotlin im Jahr 2026? Die Ökosystem-Zusammenfassung (Teil 1) zeigte für keine der beiden Sprachen größere Nachteile, und diese Modellierungs-Zusammenfassung fällt für beide ebenso positiv aus. Solange man also nicht an Multiplattform-Entwicklung interessiert ist (wo Kotlin klar vorne liegt, siehe Teil 2) oder nicht auf neuere JDK-Versionen aktualisieren kann (was Kotlin zum klaren Sieger machen würde, denn es kann die neuesten Features bis hinunter zu JDK 8 kompilieren), ist das Rennen auf der JVM knapp.

Für mich gewinnt trotzdem Kotlin, weil unveränderliche Collections dort die bessere Typsicherheit haben, weil sich typsichere Builder modellieren lassen, weil die Nullsicherheit deutlich stärkere Garantien gibt als in Java und wegen der kompakten Ausdrucksstärke, die Code erlaubt, der sich wie fachliche Prosa liest.

Das heißt nicht, dass Kotlin fehlerfrei wäre. Der Anhang listet einige der Schmerzpunkte auf, für alle, die es interessiert. Es heißt auch nicht, dass das Ergebnis in fünf Jahren dasselbe sein wird. Beide Sprachen werden sich weiterentwickeln, und dieser Vergleich wird in fünf Jahren anders aussehen. Es stehen spannende Zeiten bevor. Mit JDK 28 (März 2027) kommt ein Preview der lang, lang erwarteten value Objects, und es gibt einen Entwurf, der Nullgarantien in die JVM bringen würde („Null-Restricted and Nullable Types“ draft). Kotlin hat kürzlich context-Parameter ergänzt (Kotlin 2.4, Juni 2026), flexiblere Function Receivers, und könnte bald Error Union Types bekommen. Kotlin hat das Rennen 2026 gewonnen, aber 2031 könnte Java die Krone holen.


Anhang – Wo Kotlin einen stolpern lässt

Dies ist ein ergänzender Abschnitt, der sammelt, womit Kotlin überraschen oder Reibung erzeugen kann. Die wichtigsten Punkte wurden in der Artikelserie behandelt, aber nicht an einer Stelle zusammengetragen. Das gibt zugleich die Gelegenheit, ein paar zusätzliche zu besprechen, die kleinere Ärgernisse sind, bisher aber keine Erwähnung gefunden haben.

Zuerst die wichtigsten, jeweils mit der Begründung, warum sie kein Ausschlusskriterium sind:

  • Kotlin erwartet von Entwicklern, dass sie fortlaufend aktualisieren. Die Migrations-Ergonomie ist allerdings gut (Teil 1).
  • Kotlin hat weit weniger praktizierende Entwickler als Java. Dafür bietet Kotlin eine sehr gute Lernkurve (Teil 1).
  • Kotlin hat Operator Overloads. Java hat sie aus Angst vor Missbrauch nicht aufgenommen. Ich halte diese Sorge bei Kotlin für unbegründet, weil man nicht direkt den Operator überlädt, sondern einen semantischen Namen. Man überlädt div, nicht /. Das bietet einen semantischen Test: Man kann den Code aussprechen und prüfen, ob er Sinn ergibt (Teil 3).
  • Kotlin hat großartige Interoperabilität mit Java, aber es gibt Situationen, in denen man in Probleme läuft (Teil 3).
    • Java-Serialisierungsbibliotheken und ORM-Mapper verlangen manchmal einen parameterlosen Konstruktor, was bedeutet, dass man dem null-Marker des eigenen Codes nicht mehr trauen kann.
    • Java kann null-Marker haben, aber es gibt keine Garantie, dass sie durchgesetzt werden, was bedeutet, dass man ihnen in Kotlin-Code nicht trauen kann.
    • Kotlin hat kein static-Schlüsselwort. Stattdessen werden static-Funktionen über ein sogenanntes companion object und eine spezielle @JvmStatic-Annotation modelliert. Wenn eine Java-Bibliothek oder ein Framework unzählige static-Funktionen im Kotlin-Code verlangt, wird das schnell lästig. Der Code ist nicht schwer zu schreiben, nur umständlich.

Daneben gibt es in Kotlin noch einiges, das bisher nicht erwähnt wurde:

  • Kotlin hat keine Checked Exceptions. Ruft eine Kotlin-Funktion eine Java-Funktion auf, die mit throws annotiert ist, wird sie wie eine RuntimeException behandelt. Man muss sie nicht fangen. Angesichts dessen, wie umstritten Checked Exceptions sind, mag das für manche sogar ein Vorteil sein. Für mich war es nie ein großes Problem, hat mich aber durchaus ein, zwei Mal stolpern lassen.
  • Kotlin bietet deutlich mehr Orte für Geschäftslogik als Java. In Java landet die Logik entweder in der Domänen-Klasse (z. B. in JRental) oder in einer Service-Klasse (z. B. hat JRentalService die Logik und JRental nur die Daten). In Kotlin gibt es viel mehr Möglichkeiten, und die können Modellierungsverwirrung stiften:
    • In einer Domänen-Klasse
    • In einer Service-Klasse
    • Als Extension Function für eine Domänen-class
    • Als Top-Level-fun, der eine Domänen-class übergeben wird
    • Als Top-Level-fun mit context-Parameter (Kotlin 2.4, Juni 2026), der eine Domänen-class übergeben wird
  • Kotlin erlaubt mehrere Top-Level-Klassen und -Funktionen in derselben Datei. Java hat die Regeln auf eine Datei = eine public Top-Level-Klasse vereinfacht. In Kotlin kann eine Datei jede beliebige Kombination aus class und fun enthalten, und alle können public sein.
  • Kotlin hat try, catch und finally, aber kein try-with-resources. In Java lässt sich try (FileReader fr = new FileReader(path)){ /* reading code here */ } schreiben, und der FileReader wird nach dem Block automatisch geschlossen. Mit catch(){} lassen sich Exceptions beim Lesen behandeln und mit finally {} abschließende Aufräumarbeiten erledigen. In Kotlin lautet das Äquivalent new FileReader(path).use{ /* reading code here */ }, was den FileReader nach dem Block ebenfalls schließt. Das ist gut, bis Exceptions behandelt werden sollen. Dann muss der .use {}-Block in einen try-catch-Block eingewickelt werden. In Java liegt der Code auf derselben Einrückungsebene, in Kotlin liegt use {} eine Ebene tiefer. Das ist nicht weiter schlimm, fühlt sich aber wie ein Rückschritt bei der Prägnanz an.
  • Kotlin hat keinen Package-Private-Modifizierer. In Java gibt es vier Sichtbarkeitsmodifizierer. Ohne expliziten Modifizierer gilt die Java class als package private. Eine solche class ist von außerhalb ihres package nicht erreichbar. Das ist eine einfache Möglichkeit, Komponenten zu bauen, bei denen nur wenige Klassen für außenstehenden Code sichtbar sind (das nennt sich Package-by-Component). In Kotlin gibt es diesen Modifizierer nicht. Es gibt nur public, private und internal.
  • Kotlins Sichtbarkeitsmodifizierer internal ist in der Praxis nicht nützlich. Eine als internal markierte class sollte nur innerhalb ihres Moduls sichtbar sein (eine Menge gemeinsam kompilierter Kotlin-Dateien, z. B. ein Gradle- oder Maven-Projekt). Das wäre wirklich nützlich, lässt sich aber ohne JVM-Unterstützung leider nicht erreichen. Am Ende wird internal im Bytecode doch zu public. Der Kotlin-Compiler verstümmelt lediglich den Namen, hängt etwa das Postfix $module_name an Methodennamen, damit sie sich aus Java schwerer aufrufen lassen. Das ist ein bisschen schade, denn Java hat seit JDK 9 ein Modulsystem, das internal tatsächlich möglich gemacht hätte. In der Praxis habe ich den Sichtbarkeitsmodifizierer internal nie verwendet.
  • In Kotlin ist override ein Schlüsselwort, das zum Kompilieren erforderlich ist, wenn eine Methode überschrieben wird. In Java lässt sich überschreiben, ohne eine Methode mit @Override zu annotieren. Mir gefällt der Kotlin-Ansatz, weil er zwingt, die eigene Absicht explizit zu machen.