Heute ist das Gegenteil der Fall: Java entwickelt sich in einem rasanten Sechsmonatsrhythmus weiter, und Anwendungen müssen Schritt halten, damit sich keine technischen Schulden anhäufen. In diesem Artikel betrachten wir die Vorteile und Herausforderungen der Modernisierung und zeigen, wie KI dabei helfen kann, welche Möglichkeiten sie eröffnet, wo Schwierigkeiten entstehen und wie wir damit umgehen können.
Fragen Sie Entwickler:innen bei einer User Group oder auf einer Konferenz, welche Java-Version ihre Anwendung nutzt, und Sie werden – je nachdem, wen Sie fragen – unterschiedliche Antworten hören. Manche murmeln widerwillig acht, elf oder siebzehn, andere nennen begeistert 21 oder 25. Diese kurze Umfrage auf mehreren jüngeren Konferenzen zeigt: Bei den eingesetzten Versionen ist praktisch alles vertreten.
Vorteile der Java-Modernisierung
Wenn unsere Anwendungen möglichst nah an aktuellen Java-Versionen bleiben, hat das viele Vorteile:
- Wir können neuere Features nutzen, die den Code ausdrucksstärker, weniger fehleranfällig sowie einfacher zu entwerfen, zu lesen und zu warten machen.
- Wir können Features wie Virtual Threads einführen, die erhebliche architektonische Auswirkungen auf die Skalierung einer Anwendung haben können.
- Wir sind für Entwickler:innen attraktiver – die meisten von uns arbeiten lieber mit modernem als mit veraltetem Code.
- Wir können sicherheitsrelevante Updates leichter zeitnah übernehmen.
- Um ein Framework oder eine Library zu aktualisieren, müssen wir möglicherweise zunächst auf die dafür erforderliche Basisversion wechseln.
- Wir senken die Kosten für Upgrades, wenn wir mit den Versionswechseln Schritt halten und nicht zurückfallen.
Können wir nicht einfach die JVM-Version aktualisieren und es dabei belassen? Wenn wir diesen einfachen Weg wählen, schöpfen wir die Vorteile einer neueren Java-Version nicht vollständig aus. Außerdem könnten einige Methoden inzwischen deprecated sein, und wir möchten diese unschönen Deprecation Warnings nicht im Code sehen – insbesondere dann nicht, wenn wir die bewährte Praxis befolgen, Warnungen als Fehler zu behandeln. Mit neueren Versionen Schritt zu halten, ist grundsätzlich sinnvoll. Warum also zögern wir dennoch?
Stay tuned
Regelmäßig News zur Konferenz und der JAX-Community erhalten
Herausforderungen der Modernisierung
Die Modernisierung von Anwendungen bringt beinahe ebenso viele Herausforderungen wie Vorteile mit sich. Einige davon kennen Sie möglicherweise aus Ihren Teams:
- Die Modernisierung kostet Zeit.
- Sie erfordert Aufwand und Geld.
- Entwickler:innen kennen eventuell viele der möglichen Verbesserungen nicht.
- Wer bei den Änderungen an Sprache oder JDK auf dem Laufenden geblieben ist, ist womöglich mit anderen Aufgaben beschäftigt und steht für das Upgrade nicht zur Verfügung – das lässt sich personell nicht beliebig skalieren.
- Wir müssen große Mengen Code aktualisieren.
- Wir möchten mehrere Projekte oder eine große Zahl von Code-Repositorys aktualisieren.
- Wir könnten das Verhalten des Codes versehentlich nachteilig verändern.
Modernisierung ist eine Teilmenge des Refactorings, das häufig äußerst nützlich ist. Dennoch ist es schwierig, diejenigen, die den Aufwand finanzieren, von seinem Wert zu überzeugen, da der Code danach nichts anderes tut. Denn Refactoring verändert nicht, was der Code tut, sondern wie er es tut.
Angesichts der jüngsten Begeisterung für KI liegt die Frage nahe, ob sie nicht die meisten der genannten Herausforderungen bewältigen kann. Könnten wir so die Vorteile nutzen, ohne die damit verbundenen Probleme in Kauf nehmen zu müssen?
Deterministische und nichtdeterministische Tools
Für die Modernisierung von Java-Code stehen uns zwei Optionen zur Verfügung, die sich nicht gegenseitig ausschließen: deterministische und nichtdeterministische Tools. Beide haben Vor- und Nachteile; im Allgemeinen profitieren wir davon, sie miteinander zu kombinieren.
OpenRewrite ist ein Beispiel für ein deterministisches Tool. Es stellt zahlreiche sogenannte Recipes bereit, bei denen es sich im Wesentlichen um Transformationen handelt. Für die Modernisierung würden Sie ein Recipe verwenden, das Code von einer der vielen älteren Java-Versionen – beispielsweise Version 7 – auf eine der LTS-Versionen 8, 11, 17, 21 oder 25 überführen kann. Wenn Sie OpenRewrite etwa bitten, von Version 11 auf 25 zu modernisieren, transformiert es den Code schrittweise auf 17, anschließend auf 21 und schließlich auf 25. Dabei nutzt es Recipes, die den Code jeweils von einer LTS-Version auf die nächste überführen.
Ich bezeichne das als deterministisch, weil es keine Überraschungen gibt: Für einen bestimmten Code führt das Tool stets exakt dieselbe Transformation aus. Voraussetzung ist, dass wir jeweils mit dem ursprünglichen Code beginnen und dasselbe Recipe anwenden – unabhängig davon, wie oft wir es ausführen. Diese Vorhersagbarkeit ist nützlich.
KI gehört dagegen zur Kategorie der nichtdeterministischen Tools. Sie können eines von vielen KI-Tools bitten, Ihren Code auf eine neue Java-Version zu modernisieren. Wie wir alle erfahren haben, ist KI sehr leistungsfähig, benötigt aber erhebliche Anleitung und Kontrolle, wenn sie Code erstellt oder transformiert. Ich bezeichne sie als nichtdeterministisch, weil der generierte Code jedes Mal anders ausfallen kann, wenn Sie ein bestimmtes Modell um die Modernisierung eines Codeabschnitts bitten. Sie sollten daher nicht erwarten, dass es bei verschiedenen Durchläufen exakt denselben Code erzeugt.
Das liegt in der Natur von LLMs: Sie nutzen Pattern Matching und Inferenz, um zu bestimmen, wie sie eine Aufgabe bearbeiten. Sie folgen dabei keiner vorhersehbaren Abfolge. Entsprechend kann das Ergebnis bei jedem Durchlauf anders ausfallen.
Damit stellt sich die Frage, ob wir von den deterministischen und nichtdeterministischen Optionen einer den Vorzug geben sollten. Wegen der jeweiligen Vor- und Nachteile fällt eine eindeutige Entscheidung jedoch schwer (Tabelle 1).
| deterministisch | nichtdeterministisch | |
|---|---|---|
| vorhersagbar | ✓ | x |
| konsistent | ✓ | x |
| ohne umfangreiche Kontrolle einsetzbar | ✓ | x |
| Fähigkeiten | begrenzt | unbegrenzt |
| JDK-bezogene Updates | ✓ | ✓ |
| Sprachänderungen | ~ | ✓ |
| Paradigmenwechsel | x | ✓ |
✓ – ja, x – nein, ~ – möglicherweise mit individuellem Aufwand umsetzbar
Tabelle 1: Vor- und Nachteile deterministischer und nichtdeterministischer Tools
Die Abwägung zwischen beiden Optionen ist klar: Die eine ist vorhersagbar und konsistent, aber in ihren Möglichkeiten begrenzt. Die andere ist sehr leistungsfähig, kann jedoch aus dem Ruder laufen. Wegen ihrer jeweiligen Vorteile empfehle ich daher häufig, beide zu kombinieren.
In diesem Artikel konzentrieren wir uns auf die zweite Option: den Einsatz von KI zur Modernisierung von Java-Code.
Java-Code mit KI modernisieren
Sie können ein beliebiges LLM verwenden; probieren Sie dabei unbedingt das Modell aus, das Sie normalerweise oder am häufigsten einsetzen. In diesem Artikel verwenden wir Claude Code [1] über die Kommandozeile.
Zunächst sehen wir uns ein kleines Beispielprojekt an. Wir besprechen einige Transformationen, die wir im Zuge der Modernisierung vornehmen möchten. Anschließend modernisieren wir das Projekt mit Claude Code, erörtern das Ergebnis und entwickeln die Lösung anhand unserer Beobachtungen schrittweise weiter.
Wenn Sie das Beispiel auf Ihrem System durcharbeiten und KI zur Modernisierung Ihres eigenen Codes einsetzen, sollten Sie sowohl ihre Leistungsfähigkeit als auch ihre Unvorhersagbarkeit im Blick behalten. Das sind zwei Seiten derselben Medaille – das eine gibt es nicht ohne das andere.
Risiken von Refactoring und Modernisierung mindern
Eine Transformation, die die Implementierung verbessert, darf das Verhalten des Codes nicht verändern. Eines der Risiken beim Refactoring – und Modernisierung ist eine Form davon – besteht darin, dass sich der Code anschließend anders verhält als ursprünglich vorgesehen. Um dieses Risiko zu mindern, benötigen wir gute Feedback-Loops: Tests, die bestätigen, dass das zuvor funktionierende Verhalten auch nach der Codeänderung erhalten bleibt.
Idealerweise verfügen wir für den zu refaktorierenden Code über automatisierte Unit-Tests. Ist das weit von der Realität entfernt, helfen auch andere Arten automatisierter Tests, etwa Functional-Tests, Acceptance-Tests oder Integrationstests. Fehlen solche Tests vollständig, müssen wir möglicherweise auf manuelle Tests zurückgreifen. Entscheidend ist, überprüfen zu können, dass die geänderte Implementierung das beabsichtigte Verhalten nicht beeinträchtigt.
Selbst bei vorhandenen automatisierten Tests möchte ich sicherstellen, dass sie den refaktorierten Code tatsächlich sinnvoll abdecken. Dazu könnten wir den Code vorübergehend so verändern, dass er ein anderes Ergebnis liefert, und anschließend prüfen, ob mindestens einer der relevanten Tests fehlschlägt. So lässt sich feststellen, ob die Tests eine Regression tatsächlich erkennen, und wir gewinnen die nötige Sicherheit.
Ein Beispielprogramm für die Modernisierung
Arbeiten wir mit einem kleinen Beispiel, bei dem wir Claude Code einsetzen und den Code modernisieren können. Der Code basiert auf den in Java 7 verfügbaren Features, wurde jedoch mit Java 8 gebaut. Wir aktualisieren ihn auf Java 25, die derzeit aktuelle LTS-Version, um die modernen Features der Sprache nutzen zu können.
Unser Beispiel umfasst neben der Maven-Konfigurationsdatei pom.xml nur zwei Source-Dateien und eine Testdatei. Die kleine Anwendung ist aufgebaut wie in Listing 1, den Test sehen Sie in Listing 2.
.
|____pom.xml
|____src
| |____test
| | |____java
| | | |____com
| | | | |____agiledeveloper
| | | | | |____example
| | | | | | |____NamesTransformerTest.java
| |____main
| | |____java
| | | |____com
| | | | |____agiledeveloper
| | | | | |____example
| | | | | | |____NamesTransformer.java
| | | | | | |____Person.java
package com.agiledeveloper.example;
import java.util.Arrays;
import java.util.ArrayList;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class NamesTransformerTest {
private NamesTransformer namesTransformer;
@BeforeEach
void init() {
namesTransformer = new NamesTransformer();
}
@Test
void testConvertNamesToUpperCaseWhenThereAreNoNames() {
assertEquals(new ArrayList<>(), namesTransformer.toUpperCase(new ArrayList<String>() {}));
}
@Test
void testConvertNamesToUpperCaseWhenThereIsOneName() {
assertEquals(new ArrayList<String>() {{ add("SAM"); }}, namesTransformer.toUpperCase(new ArrayList<String>() {{ add("Sam"); }}));
}
@Test
void testConvertNamesToUpperCaseWhenThereAreTwoName() {
assertEquals(new ArrayList<String>() {{ add("SAM"); add("JILL"); }}, namesTransformer.toUpperCase(new ArrayList<String>() {{ add("Sam"); add("JiLl"); }}));
}
@Test
void testConvertNamesToUpperCaseWhenThereAreThreeName() {
assertEquals(new ArrayList<String>() {{ add("SAM"); add("JILL"); add("PAULA"); }}, namesTransformer.toUpperCase(new ArrayList<String>() {{ add("Sam"); add("JiLl"); add("Paula"); }}));
}
@Test
void testGetFirstAndLastNamesWhenThereAreNoNames() {
assertEquals(Arrays.asList(), namesTransformer.getEndNames(Arrays.asList()));
}
@Test
void testGetFirstAndLastNamesWhenThereIsOneName() {
assertEquals(Arrays.asList("Paul"), namesTransformer.getEndNames(Arrays.asList("Paul")));
}
@Test
void testGetFirstAndLastNamesWhenThereAreTwoName() {
assertEquals(Arrays.asList("Paul", "Grace"), namesTransformer.getEndNames(Arrays.asList("Paul", "Grace")));
}
@Test
void testGetFirstAndLastNamesWhenThereAreThreeName() {
assertEquals(Arrays.asList("Paul", "Pete"), namesTransformer.getEndNames(Arrays.asList("Paul", "Grace", "Pete")));
}
@Test
void testGetFirstAndLastNamesWhenThereAreFourName() {
assertEquals(Arrays.asList("Paul", "Mary"), namesTransformer.getEndNames(Arrays.asList("Paul", "Mike", "Bob", "Mary")));
}
@Test
void testGreetSam() {
String message = "Hello Sam,\nIt is wonderful seeing you.\nHave a great day!";
assertEquals(message, namesTransformer.greetPerson(new Person("Sam", 12)));
}
@Test
void testGreetSara() {
String message = "Hello Sara,\nIt is wonderful seeing you.\nHave a great day!";
assertEquals(message, namesTransformer.greetPerson(new Person("Sara", 21)));
}
}
Wir haben einige Tests für drei Methoden der Klasse NamesTransformer. Die Tests der Methode toUpperCase() überprüfen das Verhalten bei unterschiedlich vielen Elementen in der als Argument übergebenen Liste, beginnend mit einer leeren Liste. Entsprechend prüfen die Tests für getEndNames() das Verhalten bei verschiedenen Anzahlen übergebener Elemente. Die Tests für greetPerson() überprüfen schließlich den erwarteten Text, wenn die Methode mit unterschiedlichen Argumenten aufgerufen wird.
Die Klasse Person (Listing 3) ist ein Plain Old Java Object (POJO). Sie besitzt zwei immutable Felder, name und age, sowie Getter für den Zugriff auf beide. Der Konstruktor setzt lediglich diese beiden Felder. Es handelt sich schlicht um recht ausführlichen Code.
package com.agiledeveloper.example;
public class Person {
private final String name;
private final int age;
public Person(String theName, int theAge) {
name = theName;
age = theAge;
}
public String getName() { return name; }
public int getAge() { return age; }
}
Die Klasse NamesTransformer enthält drei Methoden:
- toUpperCase() nimmt eine Liste mit Namen entgegen und gibt diese Namen in Großbuchstaben zurück.
- getEndNames() nimmt eine Liste mit Namen entgegen und gibt (sofern vorhanden) das erste und das letzte Element zurück.
- greetPerson() gibt einen mehrzeiligen String mit einer freundlichen Nachricht zurück, die eine bestimmte Person mit ihrem name anspricht.
Der Code (Listing 4) verwendet einen imperativen Stil, herkömmliche if- und else-Statements sowie den Operator +=, um mehrere String-Zeilen zu verketten – eine ziemlich ausführliche und unerwünschte Konstruktion, die wir nicht gerne vorzeigen.
package com.agiledeveloper.example;
import java.util.Arrays;
import java.util.List;
import java.util.ArrayList;
import java.util.Locale;
public class NamesTransformer {
public List<String> toUpperCase(List<String> names) {
ArrayList<String> result = new ArrayList<>();
for(int i = 0; i < names.size(); i++) {
result.add(names.get(i).toUpperCase(new Locale("en")));
}
return result;
}
public List<String> getEndNames(List<String> names) {
if(names.size() == 0) {
return Arrays.asList();
} else {
if(names.size() == 1) {
return Arrays.asList(names.get(0));
} else {
return Arrays.asList(names.get(0), names.get(names.size() - 1));
}
}
}
public String greetPerson(Person person) {
String message = "";
message += "Hello ";
message += person.getName();
message += ",\n";
message += "It is wonderful seeing you.\n";
message += "Have a great day!";
return message;
}
}
Zum Schluss betrachten wir noch die Maven-Datei pom.xml (Listing 5). Sie zeigt, dass der Code für Java 8 vorgesehen ist, und bindet JUnit ein, um die Ausführung der Tests zu ermöglichen.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.agiledeveloper.example</groupId>
<artifactId>exercise</artifactId>
<version>1.0-SNAPSHOT</version>
<name>exercise</name>
<!-- FIXME change it to the project's website -->
<url>http://www.example.com</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>8</maven.compiler.release>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.11.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<!-- Optionally: parameterized tests support -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
<plugins>
<!-- clean lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#clean_Lifecycle -->
<plugin>
<artifactId>maven-clean-plugin</artifactId>
<version>3.4.0</version>
</plugin>
<!-- default lifecycle, jar packaging: see https://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_jar_packaging -->
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.3.0</version>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</plugin>
<plugin>
<artifactId>maven-install-plugin</artifactId>
<version>3.1.2</version>
</plugin>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
<version>3.1.2</version>
</plugin>
<!-- site lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#site_Lifecycle -->
<plugin>
<artifactId>maven-site-plugin</artifactId>
<version>3.12.1</version>
</plugin>
<plugin>
<artifactId>maven-project-info-reports-plugin</artifactId>
<version>3.6.1</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
Vor der Modernisierung
Im ersten Schritt sollten wir sicherstellen, dass die Tests erfolgreich durchlaufen. Grundsätzlich sollte der Code außerdem in einer Versionsverwaltung wie git committed sein, bevor wir Änderungen vornehmen. Falls etwas schiefgeht, können wir so schnell zur funktionierenden Version zurückkehren, ohne viel Zeit zu verschwenden. In Listing 6 sehen wir das Ergebnis von mvn test.
...
[INFO] ------------------------------------- -----
[INFO] T E S T S
[INFO] -------------------------------------------
[INFO] Running com.agiledeveloper.example.NamesTransformerTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.019 s -- in com.agiledeveloper.example.NamesTransformerTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] -------------------------------------------
[INFO] BUILD SUCCESS
...
Die Testausführung zeigt, dass 11 Tests erfolgreich waren und es keine fehlgeschlagenen, fehlerhaften oder übersprungenen Tests gab. Das ist eine gute Ausgangslage für unsere Modernisierung.
Skills erstellen
KI ist ein zweischneidiges Schwert: Sie ist sehr leistungsfähig, aber äußerst unvorhersagbar. Nur weil sie einen Codeabschnitt einmal oder sogar mehrmals auf eine bestimmte Weise transformiert hat, gibt es keine Garantie, dass sie es jedes Mal konsistent tut. Der Nichtdeterminismus kann sich auf höchst frustrierende Weise bemerkbar machen. Außerdem möchten wir die Wahrscheinlichkeit minimieren, dass KI eher unerwünschten Code erzeugt – etwa eine funktionale Pipeline, die einen außerhalb der Pipeline liegenden gemeinsamen State verändert. Gibt es modernere Alternativen für eine Methode, soll die KI relevanten Code beispielsweise in diese modernen Entsprechungen überführen. Obwohl KI nichtdeterministisch ist, möchten wir ihr Anweisungen, Empfehlungen und unsere Präferenzen mitgeben können, um sie auf den gewünschten Weg zu lenken. Hier kommen Skill-Dateien ins Spiel.
Eine Skill-Datei namens SKILL.md enthält Richtlinien, Empfehlungen sowie Gebote und Verbote, die KI bei der Ausführung Ihrer Befehle berücksichtigen soll. Bei Claude Code liegen Skill-Dateien im Unterverzeichnis .claude/skills unterhalb des Top-Level-Verzeichnisses Ihres Projekts. In der Regel erstellen wir mehrere Skill-Dateien, die sich jeweils auf ein zentrales Anliegen oder Themengebiet konzentrieren. Beispielsweise können Sie der KI in einer Skill-Datei Ihre Präferenzen und Best Practices für JUnit-Tests erläutern. Eine weitere könnte Ihre Vorlieben und Abneigungen beim funktionalen Programmierstil beschreiben, eine andere Clean-Code-Praktiken, Richtlinien für Spring-bezogenen Code und so weiter.
Für unser Beispiel erstellen wir eine einzige Skill-Datei: .claude/skills/java-coding-practices/SKILL.md. In dieser Datei halten wir einige Empfehlungen fest, die die KI befolgen soll (Listing 7). Diese Skill-Datei kratzt lediglich an der Oberfläche und ist nicht vollständig. In einem realen Szenario würden wir sie mit mehreren Beispielen deutlich erweitern. Außerdem hätten wir mehrere Skill-Dateien für weitere Aspekte, etwa für funktionalen Programmierstil, den sinnvollen Einsatz von Optional, die Verwendung des Stream API oder den geeigneten Einsatz von Virtual Threads.
---
name: java—coding-practices
description: Use this when creating Java code
---
# Java Coding Practices and Preferences
Use the following Java Coding Practices and Preference when generating or transforming Java code.
## Java Version
Prefer using JDK and Java version 25. When deciding language features, choose the latest features where possible. When deciding JDK functions, prefer latest methods and avoid using any deprecated methods.
* Avoid using any preview features. Only use features that are available for general use.
## Coding Conventions
* Avoid single letter variable names. Prefer descriptive yet short name for variables.
* Avoid single letter parameter names, including lambda parameters. Use domain specific names instead.
* When creating for and if, use {} even if there is only one statement in the body.
* Give a blank line, where necessary, to improve readability of code.
* Avoid long functions.
* Prefer immutability where possible.
* Where it makes sense and possible, prefer functional style over imperative style of programming. Where possible, prefer iterating using stream rather than for or while.
* Use modern alternatives to methods in the JDK. For example, where it makes sense, use the SequencedCollection methods instead of the traditional methods.
Wenn Sie KI zum Generieren neuen Codes oder zum Refactoring vorhandenen Codes einsetzen, kann das Ergebnis unerwünscht oder unzureichend ausfallen. Nutzen Sie solche Fälle als Gelegenheit, die Skill-Dateien weiterzuentwickeln. Ergänzen Sie darin Beispiele für die betreffende Situation und zeigen Sie, welchen Code die KI stattdessen erzeugen oder in welche Form sie ihn transformieren soll. Damit erhöhen Sie die Wahrscheinlichkeit, dass die KI unter den möglichen Lösungen die von Ihnen bevorzugte wählt.
Da sich die Datei .claude/skills/java-coding-practices/SKILL.md nun im Projekt befindet, starten wir Claude Code mit dem Befehl claude und erteilen folgenden Auftrag:
> Modernize the code in this project and do not modify anything under the test directory or its subdirectories.
Beim Refactoring ist es äußerst wichtig, die Tests nicht zu verändern. Unser Ziel ist hier jedoch, die gesamte Anwendung zu modernisieren; deshalb sollen später auch die Tests moderne Features nutzen. Ändern wir Source und Tests gleichzeitig, können wir allerdings nicht mehr sicher sein, dass sich das Verhalten des Codes nicht verändert hat. Modernisieren Sie daher zunächst den Source Code, ohne die Tests anzutasten. Sind diese Arbeiten abgeschlossen und laufen alle Tests erfolgreich, folgt der nächste Schritt: Lassen Sie den Source Code unverändert und modernisieren Sie die Tests.
Wir haben Claude um eine Modernisierung gebeten, aber keine Zielversion genannt. Wir könnten sie ausdrücklich angeben. Da wir die Version jedoch bereits in der Skill-Datei festgelegt haben, können wir darauf verzichten. Vergewissern Sie sich trotzdem, dass Claude tatsächlich auf die gewünschte Version modernisiert.
Wenn Claude mit der angeforderten Aufgabe beginnt, sehen Sie etwa Folgendes:
skill(java-coding-practices)
Successfully loaded skill
Das bestätigt, dass Claude den von uns bereitgestellten Skill gefunden hat und verwendet.
Denken Sie daran, dass das bei Ihnen sichtbare Refactoring-Ergebnis von dem hier gezeigten abweichen kann – ein weiterer Hinweis darauf, dass KI nichtdeterministisch und inkonsistent ist. Sobald Claude die Aufgabe abgeschlossen hat, gibt es die Meldung in Listing 8 aus.
All 11 tests pass. Summary of the modernization (test directory untouched):
pom.xml — bumped maven.compiler.release from 8 to 25.
Person.java — converted the hand-written immutable class to a record, since it was just a final-field data holder with a constructor and two accessors. The canonical name()/age() accessors replace getName()/getAge().
NamesTransformer.java:
- toUpperCase: replaced the manual indexed loop over an ArrayList with a stream map().toList(); swapped the deprecated new Locale("en") constructor for Locale.of("en").
- getEndNames: replaced nested if/Arrays.asList branching with SequencedCollection's getFirst()/getLast() and List.of(...).
- greetPerson: replaced repeated += string concatenation with a text block and .formatted(...).
Ran mvn test against JDK 25 — build succeeds, all 11 existing tests pass unchanged.
Wenn Sie wie ich vorsichtig optimistisch sind, sollten Sie mvn test außerhalb von Claude Code auf der Kommandozeile ausführen und prüfen, ob tatsächlich alle Tests erfolgreich sind. Ein kurzer git diff sollte außerdem bestätigen, dass Claude weder Dateien im Testverzeichnis noch in dessen Unterverzeichnissen verändert hat.
Sehen wir uns die Änderungen an, die Claude vorgenommen hat. Zunächst änderte es die Datei pom.xml wie folgt:
<maven.compiler.release>8</maven.compiler.release>
in
<maven.compiler.release>25</maven.compiler.release>
Eine sinnvolle Änderung: Da wir Version 25 als Ziel angegeben haben, passte Claude die Maven-Build-Datei an die richtige Version an.
Im Verzeichnis src sehen wir, dass die Klasse Person in einen record umgewandelt wurde:
package com.agiledeveloper.example;
public record Person(String name, int age) {
}
Eine Folge dieser Änderung: Anstelle der bisherigen Methoden getName() und getAge() der Klasse Person gibt es jetzt die Methoden name() und age(). Deshalb müssen die Aufrufe von Methoden der Klasse Person in der Klasse NamesTransformer entsprechend angepasst werden. Behalten wir das im Blick, während wir uns diese Klasse genauer ansehen. Gehen wir die einzelnen Funktionen der Klasse NamesTransformer durch und betrachten, wie sie transformiert beziehungsweise modernisiert wurden.
Die Methode toUpperCase() war imperativ implementiert und nutzte die herkömmliche for-Schleife. Hier ist die modernisierte Fassung:
public List<String> toUpperCase(List<String> names) {
return names.stream()
.map(name -> name.toUpperCase(Locale.of("en")))
.toList();
}
In der Skill-Datei haben wir unsere Präferenz für einen funktionalen Stil mit dem Stream API angegeben. Zusätzlich zur Umstellung auf eine funktionale Pipeline hat Claude den inzwischen deprecated Konstruktoraufruf von Locale durch einen Aufruf der Methode of() ersetzt. Statt des älteren Collectors.toList() oder Collectors.toUnmodifiableList() verwendet es außerdem die vergleichsweise neue Methode toList() von Stream. Das entspricht unserer Präferenz für Immutability: Die Methode toList() von Stream gibt eine immutable Collection zurück. Insgesamt wurde diese Methode sehr gut modernisiert.
Betrachten wir als Nächstes die Transformation der Methode greetPerson(). In der ursprünglichen Version wurde der Operator += wiederholt aufgerufen; der Code war ausführlich und wenig ansprechend. Das Java-Feature Text-Blocks ist ein geeigneter Ersatz. Der transformierte Code zeigt, dass Claude diese Modernisierung genau getroffen hat:
public String greetPerson(Person person) {
return """
Hello %s,
It is wonderful seeing you.
Have a great day!""".formatted(person.name());
}
Zusätzlich zum Text-Block ruft der Code die neue Methode formatted() auf – ebenfalls gut umgesetzt.
Sehen wir uns nun die Methode getEndNames() an. Die ursprüngliche Implementierung von getEndNames() hatte drei Probleme:
- Sie verwendete verschachtelte if-else-Statements. Verschachtelung erhöht die Komplexität des Codes, erschwert sein Verständnis und ist fehleranfällig.
- Sie verwendete das alte Arrays.asList(), das eine Liste erzeugt, deren Elemente ersetzt werden können. Eine immutable Liste wäre ideal; die Methode List.of() ist eine deutlich bessere Alternative zu Arrays.asList().
- Der Aufruf names.get(0) zum Abrufen des ersten Elements und names.get(names.size() – 1) zum Abrufen des letzten Elements ist uns bestens vertraut. Es muss jedoch eine bessere, weniger ausführliche und ausdrucksstärkere Möglichkeit geben. Die in Java 21 eingeführte SequencedCollection bietet mit getFirst() und getLast() elegantere Alternativen zu diesen beiden Aufrufen.
In Listing 9 sehen wir, wie Claude diese Methode modernisiert. Das Ergebnis ist durchwachsen.
public List<String> getEndNames(List<String> names) {
if (names.isEmpty()) {
return List.of();
}
if (names.size() == 1) {
return List.of(names.getFirst());
}
return List.of(names.getFirst(), names.getLast());
}
Zunächst verwendet Claude anstelle der älteren ausführlichen Methodenaufrufe die Methoden getFirst() und getLast() – das ist eine gute Lösung. Außerdem hat es Arrays.asList() elegant durch die moderne Methode List.of() ersetzt. Gut gemacht, Claude!
Claude hat das verschachtelte if-else-Statement aufgelöst und dabei berücksichtigt, dass nach einem return kein else nötig ist. Dennoch trifft die Transformation meine Vorstellungen noch nicht ganz. Es wäre deutlich besser, das moderne Pattern Matching mit switch zu verwenden. Denken Sie an die frühere Empfehlung: Nutzen Sie jede beobachtete Unzulänglichkeit als Gelegenheit, die Skill-Datei oder Skill-Dateien zu aktualisieren.
Skills weiterentwickeln
Wir können die Skill-Datei um eine ausdrückliche Handlungsanweisung ergänzen – Pattern-Matching-Syntax verwenden – und beobachten, wie Claude damit umgeht. Bearbeiten wir die Datei SKILL.md wie folgt:
...
* Use modern alternatives to methods in the JDK. For example, where it makes sense, use the SequencedCollection methods instead of the traditional methods.
* Where possible, use the pattern matching with switch instead of multiple if or if-else statements.
Nachdem wir die Datei .claude/skills/java-coding-practices/SKILL.md wie oben beschrieben aktualisiert haben, starten wir Claude Code erneut und erteilen folgenden Auftrag:
> Revisit the code for any further improvements for modernizations, again do not modify anything under the test directory.
Listing 10 zeigt Claudes Antwort auf diesen Befehl mit der aktualisierten SKILL.md.
Person.java and the test file needed no changes — they're already current. In NamesTransformer.java, getEndNames was still a three-way if/early-return cascade; per your team's practice of preferring switch over multiple if/if-else, I converted it to a switch expression on names.size():
public List<String> getEndNames(List<String> names) {
return switch (names.size()) {
case 0 -> List.of();
case 1 -> List.of(names.getFirst());
default -> List.of(names.getFirst(), names.getLast());
};
}
Same behavior, more concise, and consistent with the rest of the class's style. toUpperCase and greetPerson already use current idioms (Locale.of, toList(), text blocks) and didn't need changes. Ran mvn test — all tests still pass; I didn't touch anything under src/test.
Das sieht deutlich besser aus und entspricht eher meinen Vorstellungen. Ich würde mvn test erneut auf der Kommandozeile ausführen und prüfen, ob alle Tests erfolgreich sind.
Nachdem wir den Code committed haben, können wir Claude bitten, die Tests zu modernisieren, ohne Code im Verzeichnis src/main zu verändern. Wie bereits erwähnt, müssen Änderungen an den Tests unbedingt in einem separaten Schritt von den Änderungen am Source Code erfolgen.
Stay tuned
Regelmäßig News zur Konferenz und der JAX-Community erhalten
Zusammenfassung
KI ist ein äußerst leistungsfähiges Tool und sehr nützlich, um Java-Code – oder auch Code in anderen Programmiersprachen – zu modernisieren. Ihr größter Vorteil liegt in der Geschwindigkeit, mit der sich diese Aufgabe mit geringem Aufwand erledigen lässt. Der Haken dabei: Sie ist nichtdeterministisch, unvorhersagbar und inkonsistent. Deshalb müssen wir geeignete Skill-Dateien bereitstellen, um sie anzuleiten und auf Kurs zu halten.
Außerdem benötigen wir eine wirklich gute Testbasis, um sicherzustellen, dass die Transformation des Codes das beabsichtigte Verhalten nicht verändert. Ebenso müssen wir den Code reviewen, um zu prüfen, ob die Transformation unseren Erwartungen entspricht. Jede verbesserungswürdige Stelle bietet dabei die Gelegenheit, eine oder mehrere Skill-Dateien weiterzuentwickeln und die Modernisierung iterativ fortzusetzen.
Links & Literatur
Author
🔍 FAQ
1. Wie lässt sich Java-Code mit KI modernisieren?
KI-Tools wie Claude Code können bestehenden Java-Code analysieren und auf moderne Sprachfeatures und APIs umstellen. Dazu gehören beispielsweise Java Records, das Stream API, Text Blocks oder moderne Switch Expressions. Wichtig sind klare Coding-Richtlinien, automatisierte Tests und eine sorgfältige Überprüfung der Änderungen.
2. Welche Vorteile bietet KI bei der Java-Modernisierung?
KI kann die Modernisierung von Java-Anwendungen erheblich beschleunigen und den manuellen Aufwand reduzieren. Sie unterstützt Entwicklungsteams dabei, veraltete Konstruktionen zu ersetzen, Code besser lesbar zu machen und neue Java-Features einzusetzen. Da KI nichtdeterministisch arbeitet, müssen die Ergebnisse jedoch kontrolliert werden.
3. Was ist der Unterschied zwischen OpenRewrite und Claude Code?
OpenRewrite ist ein deterministisches Refactoring-Tool, das Java-Code anhand definierter Transformationsregeln, sogenannter Recipes, modernisiert. Claude Code nutzt dagegen Large Language Models (LLMs), um auch komplexere Codeänderungen vorzunehmen. Während OpenRewrite vorhersehbare Ergebnisse liefert, bietet Claude Code größere Flexibilität. Beide Ansätze lassen sich sinnvoll kombinieren.
4. Wie kann man Java-Anwendungen von Java 8 auf Java 25 migrieren?
Bei der Migration von Java 8 auf Java 25 müssen nicht nur die Java-Version und die Build-Konfiguration aktualisiert werden. Auch bestehender Code kann moderne Sprachfeatures und APIs nutzen. KI-Tools wie Claude Code helfen beispielsweise dabei, Klassen in Records umzuwandeln, Schleifen durch Stream-Operationen zu ersetzen und veraltete Methodenaufrufe zu modernisieren. Automatisierte Tests stellen sicher, dass das erwartete Verhalten erhalten bleibt.
5. Welche Rolle spielen Skills bei der Java-Modernisierung mit Claude Code?
Skills sind Dateien mit projektspezifischen Anweisungen, Coding-Konventionen und Best Practices. Über eine SKILL.md-Datei lässt sich beispielsweise festlegen, dass Claude Code Java 25 verwenden, Immutability bevorzugen und moderne Sprachfeatures einsetzen soll. Durch die kontinuierliche Anpassung dieser Richtlinien können Entwicklungsteams die Qualität und Konsistenz der KI-generierten Änderungen verbessern.
6. Wie lässt sich das Risiko beim KI-gestützten Java-Refactoring minimieren?
Eine zuverlässige Testbasis ist entscheidend, damit die Modernisierung das Verhalten der Anwendung nicht unbeabsichtigt verändert. Vor dem Refactoring sollten alle Tests erfolgreich durchlaufen und Änderungen in der Versionsverwaltung gesichert sein. Anschließend empfiehlt es sich, zunächst den Anwendungscode und erst danach die Tests zu modernisieren. Zusätzlich sollten alle KI-generierten Änderungen durch Code-Reviews überprüft werden.





