Mein Wechsel zu Go
Im privaten Bereich bin ich von Java auf Go gewechselt. Auslöser ist, dass die Arbeit mit KI die Softwareentwicklung auf den Kopf gestellt hat. Was früher Nachschlagen, Boilerplate und viel Tippen war, erledigt ein Assistent heute in kurzer Zeit. Übrig bleibt die Frage, ob man das Ergebnis versteht, prüfen kann und später noch ändert.
Dass sich der Alltag dadurch verändert, habe ich Anfang September schon beschrieben. Hier geht es um die Folge für die Wahl der Sprache im Backend: Go auf der einen Seite, Java mit Spring Boot oder Quarkus auf der anderen. Die Punkte habe ich für mich neu geordnet. Es sind Überlegungen aus der eigenen Arbeit, keine Messreihe.
Lange sprach für ein Framework, dass es die wiederkehrende Verdrahtung schon mitbringt. HTTP, Dependency Injection, Security, Datenbank, Konfiguration, Validierung: Das musste niemand für jedes Projekt neu zusammensetzen. Weniger eigener Code hieß weniger Aufwand. Spring Boot treibt das weit. Starter bündeln Abhängigkeiten, die zueinander passen.
Diesen Teil erzeugt eine KI heute schnell. Ein Handler, eine Middleware, ein Repository: Das ist kein großer Posten mehr. Die Kosten sitzen danach. Was kostet es, den Code zu lesen, zu testen und über Jahre zu verändern?
Damit verlieren Zeilen an Gewicht. 300 klare, langweilige Zeilen, die eine KI in kurzer Zeit schreibt, stören mich nicht. Mich stören wenige Zeilen, deren Verhalten von Annotationen, Konventionen und der Framework-Version abhängt. Gezählt wird für mich nicht mehr, wie viel Code dasteht, sondern wie viel ich prüfen muss, bis ich dem Verhalten traue.
Go hält die Sprache klein. Keine Metaprogrammierung über Annotationen, keine klassische Vererbung, im normalen Dienst kein Dependency-Injection-Container. Der Weg ist eng. Soll eine KI einen Endpunkt erzeugen, landet sie schnell bei dem, was man auch von Hand schreiben würde:
|
|
In Java sind viele Wege legitim. Spring MVC oder WebFlux, JAX-RS oder Quarkus REST, CDI oder Spring DI, Constructor Injection oder Field Injection, JPA, JDBC, Spring Data, Panache. Jeder davon hat einen Grund. Für eine KI heißt das auch mehr Varianten und mehr Gelegenheit, APIs und Versionen zu vermischen. Ein engerer Weg erzeugt gleichförmigeren Code. Den kann ich schneller gegenlesen.
Ein typischer Go-Dienst bleibt dabei explizit. Von main über http.Server, Middleware und Handler in den Service, von dort ins Repository und in database/sql. Was passiert, steht in dem Code, der vor einem liegt.
Bei Spring und Quarkus kommt oft eine zweite Schicht dazu: Annotationen, Dependency Injection, Auto-Configuration, Interceptors, Proxies, der Lebenszyklus der Beans, Profiles, Classpath Scanning, das Verhalten des ORM, Konventionen des Frameworks und beim nativen Image noch die AOT-Konfiguration. Eine KI kennt Spring. Darum geht es nicht. Aus der Methode allein lässt sich oft nicht mehr sicher sagen, was zur Laufzeit passiert. Dasselbe gilt für das Modell, das den Code schreibt oder erklärt.
@Transactional, @PreAuthorize, @Cacheable oder @Async können eine Methode stark verändern. In vielen Projekten ist das genau der Nutzen. Zum Prüfen heißt es: Die Bedeutung liegt nicht vollständig im Methodenkörper. Beim Debuggen merke ich dasselbe. In Go folge ich dem Aufruf. Im Framework stehe ich öfter vor einem Proxy oder einer Konvention, die ich erst wieder nachschlagen muss.
Erzeugen ist damit der kleinere Teil. Prüfen ist der größere. In Go sehe ich router.HandleFunc, db.QueryContext, http.Client.Do, go worker() und json.NewDecoder direkt auf der Seite.
Eine KI schlägt gern eine Bibliothek vor. In Java wird daraus schnell Spring, Hibernate, Jackson, Apache Commons, Guava, MapStruct, Lombok und ein weiter transitiver Graph. Spring Boot hält das mit verwalteten Dependency-Sets im Zaum. Das ist ein echter Vorteil des Frameworks, kein Ballast.
Go bringt für einen Dienst vieles in der Standardbibliothek mit: net/http, encoding/json, crypto, database/sql, log/slog, testing, context, html/template. Meine Regel ist schlicht. Zuerst die Standardbibliothek, eine externe Abhängigkeit nur mit einem Grund. Der Graph bleibt klein, und der erzeugte Code bleibt prüfbar. Eine hineinkompilierte Bibliothek fällt damit nicht aus der Verantwortung. Hat sie eine Lücke, baue und liefere ich das Binary neu.
Dieselbe Enge hilft beim Bauen. Ein Go-Projekt sieht fast immer gleich aus: go.mod, go.sum, cmd/, internal/. Bauen ist go build ./..., Testen ist go test ./..., Formatieren ist gofmt, Aufräumen ist go mod tidy. Ein Coding-Agent muss wenig raten.
Bei Java muss derselbe Agent Maven oder Gradle verstehen, die Framework-Version, die Java-Version, Plugins, Annotation Processors, Profiles, BOMs, bei einem nativen Image noch GraalVM oder Mandrel, dazu Properties und generierte Quellen. Das kann ein gutes Werkzeug. Jede weitere Achse ist eine weitere Stelle, an der eine Annahme danebenliegen kann.
Für Container brauche ich linux/amd64 und linux/arm64. In Go sind Betriebssystem und Architektur normale Parameter des Builds:
|
|
Mit CGO_ENABLED=0 reicht im Extrem ein Image, das nur das Binary enthält. Dazu kommen bei Bedarf CA-Zertifikate und Zeitzonendaten. Eine JVM liegt nicht im Image. Weniger Komponenten heißt weniger, was ich patchen, inventarisieren und auf bekannte Lücken prüfen muss.
Java auf der JVM ist für mehrere Architekturen unkompliziert, weil derselbe Bytecode auf beiden läuft. Ein natives Image ist dort ein zusätzlicher Modus. Spring Boot kann das mit GraalVM. Die Dokumentation beschreibt dafür AOT-Verarbeitung, Native Build Tools und die Einschränkungen des Native Image. Quarkus ist auf diesen Weg stärker ausgerichtet. Der Leitfaden zum nativen Executable nennt die Abwägung selbst: Der native Build dauert deutlich länger, Reflection und dynamisches Laden brauchen besondere Behandlung, und bei lange laufenden Diensten kann die JVM beim Durchsatz vorne liegen. Für mehrere Plattformen auf einmal gilt außerdem, dass quarkus.jib.platforms bei Native Images nicht greift, weil Cross-Compilation nicht unterstützt wird. Das steht so in der Doku zu Container-Images.
Ich trenne das so. Bei Java ist nativ ein Auslieferungsmodus. Bei Go ist das native Binary das normale Programm.
Startzeit, Speicher und Durchsatz würde ich gern in derselben Reihe nennen. Dafür habe ich keine eigenen Zahlen. Ich erwarte bei einem kleinen Dienst einen schnellen Start und einen geringen Speicherbedarf, und ich halte es für plausibel, dass eine JVM mit JIT bei langer Laufzeit beim Durchsatz vorne liegen kann. Belegen kann ich das hier nicht.
Sicher wird der Code durch die Prüfung und durch feste Bausteine. Dass er explizit dasteht, reicht nicht. Spring Security bringt viel Wissen als Voreinstellung mit. Schutz gegen verbreitete Web-Angriffe ist, wo es geht, standardmäßig an. Das beschreibt die Dokumentation zu Exploits. Header, CSRF und ein durchdachtes Session-Verhalten muss dort niemand für jedes Projekt neu erfinden.
In Go gehört diese Schicht mir, sobald ich sie selbst schreibe. Eine KI kann Authentifizierung, Autorisierung, CSRF, CORS, Sessions, Cookie-Attribute, Security-Header, Rate Limiting, OIDC, JWT, Passwort-Hashes, Request-Limits, Timeouts und TLS als Middleware hinlegen. Danach ist es meine Implementierung.
Die Frage ist, was der einzelne Dienst davon überhaupt selbst absichern muss. Steht davor ein Reverse Proxy oder ein API-Gateway, verschiebt sich die Antwort. Der Weg läuft aus dem Internet zum Gateway. Dort endet TLS, dort liegen Anmeldung, Rate Limiting, Limits für die Anfrage, Security-Header und das Routing. Erst danach kommt der Go-Dienst, und von dort die Datenbank oder interne Dienste. Viele Funktionen, die Spring Security im Prozess mitbringen könnte, muss der Dienst dann gar nicht selbst bauen. Das spricht für einen schlanken Go-Dienst.
Ich trenne das in drei Ebenen.
Am Rand lässt sich vieles zentralisieren. TLS, die Anmeldung nach außen, OIDC, Limits für Größe und Zeit, Rate Limiting, grundlegende Header, CORS und teilweise der Schutz vor CSRF. Das ist Arbeit fürs Gateway.
Zwischen den Diensten kann die Infrastruktur ebenfalls übernehmen, wer wen aufrufen darf und ob der Transport geschützt ist. Gateway, Service Mesh oder mTLS gehören hierher.
Die fachliche Entscheidung bleibt beim Dienst, nah an der Logik. Das Gateway kann wissen, wer ich bin und welche Rollen ich habe. Ob ich DELETE /projects/4711 ausführen darf, hängt davon ab, ob mir das Projekt gehört. Das weiß das Gateway in der Regel nicht. Die Anmeldung lässt sich gut zentralisieren. Die Autorisierung nur zum Teil.
Ist der Dienst nur über diesen kontrollierten Weg erreichbar, schrumpft die Fläche, die von außen angreifbar ist. Im Dienst reicht dann die Fachlogik, die fachliche Rechteprüfung, die Prüfung der Eingaben, die Persistenz, ein Audit und die Observability. TLS, OIDC, Anmeldung, Rate Limiting, Größenlimits, Routing und allgemeine Security-Regeln liegen auf der Plattform, dazu bei Bedarf die Identität der Dienste untereinander.
Spring Security bringt sehr viele Funktionen mit. Das wiegt weniger, sobald diese Funktionen bewusst auf einer anderen Schicht liegen. Spring Boot wird oft als komplette Anwendungsplattform genutzt: HTTP, Anmeldung, Rechte, Security, Fachlogik, Persistenz und Observability in einem Prozess. Eine aufgeteilte Architektur legt Eingang, TLS, Identität, Anmeldung, Verkehrsregeln und allgemeine Kontrollen in die Infrastruktur. In der Anwendung bleiben fachliche Rechte, Validierung, Fachlogik und Persistenz. Je klarer die Plattform diese Trennung auch durchsetzt, desto weniger ist ein großes Anwendungsframework allein deshalb nötig, weil es Infrastruktur und Security schon enthält.
Das passt zu dem, was ich von der KI erwarte. Sie schreibt den expliziten Go-Code für die Fachlichkeit. Sicherheitskritische Querschnittsfunktionen schreibt sie nicht bei jedem Dienst neu. Die kommen aus wenigen festen Bausteinen der Plattform.
Die Grenze will ich dabei klar halten. Prüfung der Eingaben und fachliche Autorisierung bleiben im Dienst. Sonst wird aus der Auslagerung ein blindes Vertrauen in alles, was hinter dem Proxy ankommt.
Dafür muss der Dienst feststellen können, dass die Anfrage wirklich vom Gateway kommt. Vertraut er einfach einem Header wie X-User: admin, gilt der Schutz nur, solange niemand den Dienst direkt erreicht. Ein Aufruf am Gateway vorbei könnte denselben Header setzen. Deshalb gehören mehrere Schichten zusammen: Der Dienst ist netzwerkseitig nicht offen, der Aufruf zwischen Gateway und Dienst ist authentifiziert, die Identität wird verlässlich weitergegeben, und die fachliche Entscheidung fällt im Dienst. Je nach Umgebung sind das ein privates Netz, mTLS, signierte Tokens, Network Policies oder eine Kombination davon.
Die Stellen, die im Dienst bleiben, will ich nicht bei jedem Endpunkt neu erzeugen lassen. Ein kleiner Baustein, den ich selbst gelesen habe, reicht. Der generierte Code ruft ihn auf.
Dazu kommen die Werkzeuge, die Go selbst nennt: go test, der Race Detector, Fuzzing und govulncheck. govulncheck schaut, ob eine verwundbare Funktion vom Programm aus aufgerufen wird, und nicht nur, ob die Bibliothek im Modulgraphen steht. Die Security Best Practices führen das aus. In der Pipeline heißt das für mich: formatieren, go vet, Tests, Race Detector, govulncheck, und am Ende ein Mensch. Die KI darf den Code schreiben. Ob er sicher ist, entscheidet sie nicht.
Für einen schmalen Backend-Dienst geht die Rechnung bei mir in Richtung Go. Sobald viele schwere Integrationen dazukommen, dreht sie sich. OAuth und OIDC im großen Stil, Transaktionen, JPA, Validierung, Messaging, Kafka, Observability, verteiltes Tracing, Datenbankmigrationen, Identitäten in einer Organisation und eine komplexe Persistenz sind in Spring und Quarkus gelöst und aufeinander abgestimmt.
Wenn die Alternative lautet, zwanzig davon in Go selbst zu bauen, ist das Framework die kleinere Aufgabe, auch wenn es in sich komplizierter ist. Die Schwierigkeit steckt dann im Fachlichen. Das Framework nimmt sie nicht weg. Es verhindert, dass ich sie ein zweites Mal als Infrastruktur baue.
Messbare Bewertungskriterien habe ich nicht. Keine Vergleichswerte für Bauzeit, Speicher, Image-Größe, Anzahl der Abhängigkeiten, die Fläche bekannter Lücken, erzeugte Zeilen oder die Dauer eines Reviews. Einen identischen Dienst in Go, Spring Boot und Quarkus habe ich nicht gebaut.
Vieles in diesem Text sind deshalb Überlegungen. Ich orientiere mich im privaten Bereich daran. Sie ersetzen keine Auswertung, die man an Zahlen festmachen könnte, und sie sagen nicht, ob dieselbe Abwägung in einem anderen Umfeld genauso ausgeht.
Getragen hat den Wechsel für mich die Richtung: kleine Sprache, Ablauf im sichtbaren Code, billiges Erzeugen, prüfbarer Code und ein natives Binary als Normalfall. Allgemeine Security-Kontrollen lege ich an die Plattform. Fachliche Rechte und die Prüfung der Eingaben bleiben im Dienst, in wenigen Bausteinen, die ich selbst lese. Die KI schreibt diese Schicht nicht bei jedem Endpunkt neu.
Rückmeldungen, Fragen oder Hinweise sind wie immer herzlich willkommen.