Junior-Entwickler im Zeitalter der KI: Was man lernen sollte

1. Übersicht

Code-Generierungswerkzeuge können jetzt Controller, Entity oder Testvorlagen in wenigen Sekunden schreiben. Viele Anfänger im Beruf machen sich daher Sorgen, dass Junior-Positionen verschwinden werden. Dieser Artikel trifft keine Vorhersagen über die Zukunft. Stattdessen konzentriert er sich auf eine praktischere Frage: Wenn das Schreiben von Code keine Herausforderung mehr ist, welche Fähigkeiten solltest du trainieren?

Nachdem du diesen Artikel gelesen hast, wirst du eine Liste von Fähigkeiten haben, die Priorität haben. Jede Fähigkeit kommt mit einem Java-Beispiel, das du sofort ausführen kannst. Der Artikel macht auch deutlich, wann du Aufgaben nicht an Code-Assistenten delegieren solltest.

2. Warum diese Frage gerade an Aufmerksamkeit gewinnt

Früher wurden Anfänger typischerweise mit repetitiven Aufgaben betraut – CRUD-Code schreiben, DTO-Mapping oder kleine Fehler beheben. Das war auch ihre Art zu lernen. Heute können die meisten dieser Aufgaben automatisiert werden. Die Kosten für Code-Erstellung sind drastisch gesunken.

Die Kosten für Fehler bleiben aber gleich. Der generierte Code kann kompiliert werden, sieht vernünftig aus und besteht einen einfachen Test, aber er kann immer noch fehlerhaft sein bei subtilen Details: Währungsbehandlung, Concurrency, Transaktionen, Sicherheit. Du bist immer noch derjenige, der die Verantwortung für den Code in der Produktion trägt.

Der Wert verschiebt sich daher von »Code schreiben können« zu drei Aufgaben:

  • Code bewerten können – ist er richtig oder falsch, und warum?
  • Verhalten verifizieren mit Tests.
  • Runtime, Framework und das System verstehen, in dem der Code läuft.

Die folgenden Abschnitte gehen nacheinander durch jede Fähigkeit.

3. Code lesen und reviewen ist besser als schnell Code schreiben

Die erste Fähigkeit ist, einen Code-Ausschnitt anzuschauen, der »in Ordnung« aussieht, und zu erkennen, dass er falsch ist. Angenommen, du bittest einen Code-Assistenten, eine Funktion zur Berechnung der Gesamtsumme einer Bestellung zu schreiben, und erhältst folgendes:

import java.util.List;

record OrderLine(String product, double price, int quantity) {}

record Order(List<OrderLine> lines) {
    double total() {
        return lines.stream()
                .mapToDouble(l -> l.price() * l.quantity())
                .sum();
    }
}

public class Main {
    public static void main(String[] args) {
        var order = new Order(List.of(
                new OrderLine("Stift", 0.1, 1),
                new OrderLine("Notizbuch", 0.2, 1)));
        System.out.println(order.total());
    }
}

Der Code ist kurz und lesbar, aber das Ergebnis ist:

0.30000000000000004

double stellt Zahlen im binären Gleitkommaformat dar. Daher werden 0.1 und 0.2 nicht exakt gespeichert. Bei Währungen können sich diese Fehler über Tausende von Transaktionen ansammeln und zu Differenzen bei der Abstimmung führen. Die korrigierte Version nutzt BigDecimal:

import java.math.BigDecimal;
import java.util.List;

record OrderLine(String product, BigDecimal price, int quantity) {
    BigDecimal subtotal() {
        return price.multiply(BigDecimal.valueOf(quantity));
    }
}

record Order(List<OrderLine> lines) {
    BigDecimal total() {
        return lines.stream()
                .map(OrderLine::subtotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

Es gibt drei Dinge zu beachten:

  • price hat den Typ BigDecimal. Du solltest Werte mit new BigDecimal("0.1") erstellen, nicht mit new BigDecimal(0.1).
  • subtotal() multipliziert den Preis mit der Menge, ohne durch double zu gehen.
  • reduce beginnt mit BigDecimal.ZERO, also gibt eine leere Bestellung 0 zurück statt einen Fehler zu werfen.

Das ist genau die Fähigkeit, die Werkzeuge nicht für dich ersetzen können. Du musst Fragen stellen über Datentypen, Grenzwerte und leere Fälle. Wenn du Code reviewst, frag dich selbst: »Welche Input würde diese Funktion zu Fehler bringen?«

4. Test wie eine Spezifikation schreiben

Ohne Tests weißt du nicht, ob ein Code-Ausschnitt korrekt ist. Tests sind auch die klarste Möglichkeit, was du willst, zu beschreiben. Je klarer die Beschreibung, desto leichter ist es, sowohl generierte als auch von Kollegen geschriebene Code zu verifizieren.

Bei einem Spring-Boot-Projekt brauchst du nur eine Abhängigkeit, um JUnit 5 und AssertJ zu haben:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Test für die oben genannte Order-Klasse:

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.util.List;

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

class OrderTest {

    @Test
    @DisplayName("Gesamtsumme addiert Dezimalzahlen präzise")
    void totalIsExactForDecimals() {
        var order = new Order(List.of(
                new OrderLine("Stift", new BigDecimal("0.1"), 1),
                new OrderLine("Notizbuch", new BigDecimal("0.2"), 1)));

        assertThat(order.total()).isEqualByComparingTo("0.3");
    }

    @Test
    @DisplayName("Leere Bestellung hat Gesamtsumme von 0")
    void emptyOrderTotalIsZero() {
        var order = new Order(List.of());

        assertThat(order.total()).isEqualByComparingTo(BigDecimal.ZERO);
    }

    @Test
    @DisplayName("Gesamtsumme multipliziert korrekt nach Menge")
    void totalMultipliesByQuantity() {
        var order = new Order(List.of(
                new OrderLine("Buch", new BigDecimal("12.50"), 3)));

        assertThat(order.total()).isEqualByComparingTo("37.50");
    }
}

Ein paar Details zum Lernen:

  • @DisplayName dokumentiert die Geschäftsregel in verständlicher Sprache. Einen Test-Report zu lesen bedeutet, das System verstehen zu wollen.
  • isEqualByComparingTo vergleicht nach Wert. new BigDecimal("0.3").equals(new BigDecimal("0.30")) gibt false zurück, da sie unterschiedliche Scale haben. Das ist ein häufiger Fehler.
  • Jeder Test prüft genau ein Verhalten: normalfall, leerer Fall, Multiplikation.

Hinweis: Lass nicht zu, dass ein Tool sowohl Code als auch Tests schreibt und du dann einfach grünes Licht siehst. Wenn beide die Anforderung falsch verstehen, sind die Tests immer noch erfolgreich. Du solltest zumindest Tests für wichtige Geschäftsregeln selbst schreiben.

5. Die Runtime verstehen: Concurrency lässt sich nicht erraten

Die meisten schwierigen Fehler tauchen nur auf, wenn viele Threads gleichzeitig laufen. In Spring sind alle Beans standardmäßig Singleton, d.h. jede Request nutzt die gleiche Instanz. Der folgende Codeabschnitt sieht harmlos aus:

import java.util.concurrent.Executors;

class ReportService {
    private int processedCount = 0;

    void process() {
        processedCount++;
    }

    int processedCount() {
        return processedCount;
    }
}

public class CounterDemo {
    public static void main(String[] args) {
        var service = new ReportService();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                executor.submit(service::process);
            }
        }

        System.out.println("Verarbeitet: " + service.processedCount());
    }
}

Executors.newVirtualThreadPerTaskExecutor() von Java 21 erstellt einen Virtual Thread für jede Aufgabe. Der try-with-resources-Block wartet darauf, dass alle Aufgaben abgeschlossen sind, bevor er geschlossen wird. Wir erwarten, dass 10000 ausgegeben wird, aber in der Praxis sehen wir oft eine kleinere Zahl, und jeder Lauf gibt eine andere Zahl aus:

Verarbeitet: 9871

Der Grund ist, dass processedCount++ keine atomare Operation ist. Sie besteht aus drei Schritten: Lesen, Erhöhen und Schreiben. Zwei Threads können denselben Wert lesen, was bewirkt, dass eine Inkrementierung verlorengeht. Die korrigierte Version:

import java.util.concurrent.atomic.AtomicInteger;

class ReportService {
    private final AtomicInteger processedCount = new AtomicInteger();

    void process() {
        processedCount.incrementAndGet();
    }

    int processedCount() {
        return processedCount.get();
    }
}

Diesmal ist das Ergebnis immer 10000. Die breitere Lektion ist: Eine Singleton Bean sollte keinen veränderlichen Zustand pro Request halten. Wenn du einen @Service liest, solltest du nach Feldern suchen, die nicht final sind. Das sind oft die Plätze, wo Fehler versteckt sind.

Du musst nicht alle APIs aus java.util.concurrent auswendig können. Aber du musst Konzepte wie Race Condition, Visibility oder Lock verstehen, um Fehler zu erkennen, wenn du sie siehst.

6. Das Framework verstehen: Wisse, wie die »Magie« funktioniert

Spring hilft dir, weniger Code zu schreiben, indem es Annotation nutzt, aber viel passiert im Verborgenen. Ohne die zugrunde liegende Mechanik zu verstehen, wirst du überrascht sein. Das Klassiker-Beispiel ist Self-Invocation mit @Transactional:

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

import java.util.List;

@Service
public class PaymentService {

    private final PaymentRepository paymentRepository;

    public PaymentService(PaymentRepository paymentRepository) {
        this.paymentRepository = paymentRepository;
    }

    public void payAll(List<Long> paymentIds) {
        paymentIds.forEach(this::pay);
    }

    @Transactional
    public void pay(Long paymentId) {
        var payment = paymentRepository.findById(paymentId).orElseThrow();
        payment.markPaid();
        paymentRepository.save(payment);
    }
}

Auf den ersten Blick wird jeder Aufruf von pay in einer Transaktion ausgeführt. In Wirklichkeit aber nicht. Spring wendet @Transactional über einen Proxy an, der die Bean umgibt. Wenn payAll this.pay(...) aufruft, geht der Aufruf direkt zum Original-Objekt und umgeht den Proxy. Daher wird keine Transaktion geöffnet.

Es gibt zwei verbreitete Lösungen:

  • Platziere @Transactional auf der Methode, die von außen aufgerufen wird, also auf payAll, wenn du möchtest, dass der ganze Batch in einer Transaktion läuft.
  • Verschiebe pay in eine andere Bean, zum Beispiel SinglePaymentProcessor, injiziere sie dann in PaymentService, damit der Aufruf durch den Proxy geht.

Wenn du die Proxy-Mechanik verstehst, kannst du viele andere Phänomene erklären: @Async läuft nicht asynchron, @Cacheable cacht nicht, oder Annotation auf private-Methoden werden ignoriert. Dieses Hintergrund-Wissen hilft dir, schneller zu debuggen als jedes Guessingspiel.

7. Das System beobachten statt es zu erraten

Code, der auf deinem Rechner richtig läuft, bedeutet nicht, dass er richtig in der Produktion läuft. Ein Junior, der Log, Metriken und Health Checks lesen kann, wird viel wertvoller sein als viele, die nur Features schreiben können. Bei Spring Boot beginnst du mit Actuator:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Dann konfigurierst du die nötigen Endpoints:

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      show-details: when-authorized

logging:
  level:
    root: INFO
    org.springframework.transaction: DEBUG

Was jeder Teil bedeutet:

  • exposure.include öffnet nur die drei nötigen Endpoints. Alle mit "*" zu öffnen ist ein häufiger Sicherheitsfehler.
  • show-details: when-authorized zeigt Health-Details nur für authentifizierte Nutzer.
  • Das Aktivieren von DEBUG für org.springframework.transaction ermöglicht es dir, in den Logs zu sehen, wenn Transaktionen geöffnet und committed werden. Das ist der direkte Weg, den Self-Invocation-Fehler aus Abschnitt 6 zu überprüfen.

Schneller Check:

curl http://localhost:8080/actuator/health
{"status":"UP"}

Die Gewohnheit, die du hier entwickeln solltest, ist: Wenn ein Fehler auftritt, sammle Beweise von Logs, Metriken und Stack Traces, bevor du etwas reparierst. Versuche nicht zufällig verschiedene Code-Blöcke, bis der Fehler von selbst verschwindet.

8. Code-Assistenten richtig nutzen und wann man sie nicht nutzt

Code-Assistenten sind gute Werkzeuge. Sie zu vermeiden hilft dir nicht weiter. Das Wichtige ist, dass du die Kontrolle behältst. Ein vernünftiger Prozess hat vier Schritte:

  1. Schreibe selbst die Anforderungen und die wichtigsten Tests zuerst, wie in Abschnitt 4.
  2. Bitte das Werkzeug um Implementierungsvorschläge.
  3. Lies jede Zeile. Wenn du eine Zeile nicht erklären kannst, merge sie nicht.
  4. Führe Tests aus und füge Tests für Edge Cases hinzu, die dir bei der Review eingefallen sind.

Du solltest nicht Aufgaben an einen Code-Assistenten geben, oder zumindest besonders vorsichtig sein, in den folgenden Fällen:

  • Sicherheits-Code: Authentifizierung, Autorisierung, Verschlüsselung, Token-Verarbeitung. Ein kleiner Fehler hier kann große Konsequenzen haben, und falscher Code sieht oft sehr ähnlich wie richtiger Code.
  • Wenn du gerade ein neues Konzept lernst. Wenn das Werkzeug deine erste Concurrency-Übung für dich schreibt, wirst du genau den wichtigsten Teil des Lernens verpassen.
  • Sensible Daten: Gib nicht Secrets, Kundeninformationen oder internen Code in Werkzeuge, die dein Unternehmen nicht genehmigt hat.
  • Spezifisches Business: Code für Dinge, die das Werkzeug nicht versteht, wie Steuersätze oder unternehmens-spezifische Rabattrichtlinien. Frag erst die Person, die das Business versteht.

Hinweis: Ein gefährliches Zeichen ist, wenn du Code schneller mergst, als du ihn verstehst. In diesem Fall bist du kein Ingenieur mehr, du leitest einfach Code weiter.

9. Empfohlener Lernpfad für Anfänger

Du brauchst nicht alles auf einmal zu lernen. Eine sinnvolle Reihenfolge wäre:

  • Sprach-Grundlagen: Datentypen, Collections, equals/hashCode, Exception, grundlegende Concurrency.
  • Testing: JUnit 5, AssertJ, wie man Tests nach Verhalten benennt, Grenzfall-Tests.
  • Framework von Innen: Dependency Injection, Bean-Lifecycle, Proxies, Transaktionen.
  • Daten: echtes SQL, Indizes, Transaktions-Isolation, N+1-Query-Fehler mit JPA.
  • Operations: Logs lesen, Metriken, Health Checks, Debuggen auf produktionsähnlichen Umgebungen.
  • Kommunikation: vage Anforderungen in klare Akzeptanzkriterien umwandeln, Pull-Request-Beschreibungen schreiben, die einfach zu reviewen sind.

Die letzte Fähigkeit wird oft unterschätzt. Jemand, der die richtigen Fragen stellt – wie »Was passiert bei einer leeren Bestellung?« oder »Was wenn zwei Requests gleichzeitig ankommen?« – wird immer gebraucht, egal wer den Code schreibt oder welches Werkzeug dafür benutzt wird.

10. Fazit

In diesem Artikel haben wir gesehen, wie der Wert eines Junior-Entwicklers von Schreib-Geschwindigkeit zu Bewertungs-, Verifizierungs- und System-Verständnis-Fähigkeiten verlagert wird. Die Beispiele zeigen Fehler, die »gut aussehender« Code immer noch macht: double-Fehler bei Währungen, Race Conditions in Singleton Beans und @Transactional, das wegen Self-Invocation ignoriert wird. Du hast auch gesehen, wie man mit Tests und Actuator selbst verifiziert statt zu raten.

Nutze Code-Assistenten wie einen Kollegen, der schnell schreibt aber oft Fehler macht. Du nutzt seine Geschwindigkeit, aber liest immer wieder, schreibst Tests und verstehst jede Zeile vor dem Merge. So wirst du nicht ersetzt und wirst zu jemandem, auf den das Team nicht verzichten kann.

KarriereJunior-EntwicklerKIJavaTesten