Alle Artikel
12. September 2026
8 mins

Go lernen als PHP-Entwickler: fünf Denkfehler

Von Tim Rutte, Cloud & Software ArchitectThemaBackend & Plattformen

Zwei Schraubenschlüssel an derselben Mutter; der blaue greift, der graue steht einen Hauch daneben.

Wenn ein PHP-Team seinen ersten Go-Dienst schreibt, läuft der nach zwei Wochen. Er läuft auch nach sechs Monaten noch, und dann sieht man ihm an, aus welcher Sprache das Team kommt: an fünf Stellen, und zwar immer an denselben.

Das ist keine Frage von Können. Es ist die normale Folge davon, dass man eine neue Sprache mit den Begriffen der alten liest. Go sieht aus wie eine einfache Sprache, und deshalb überspringt man die Stellen, an denen es anders gemeint ist als es aussieht.

Dieser Artikel beschreibt fünf davon. Er ist für Teams geschrieben, die einen Dienst neben einem bestehenden PHP-System betreiben, nicht für Sprachvergleiche.

Denkfehler 1: Fehler sind Ausnahmen

In PHP wirft man eine Ausnahme und fängt sie weit oben. Der Kontrollfluss dazwischen bleibt sauber, und das ist bequem. Go hat keine Ausnahmen, und der erste Reflex ist, das als Umständlichkeit zu lesen.

Das Muster, das dabei entsteht, sieht so aus:

// Aus PHP mitgebracht: Fehler weiterreichen, ohne etwas hinzuzufuegen.
if err != nil {
	return err
}

Das übersetzt, funktioniert und nimmt dem Fehler unterwegs alles, was ihn nützlich macht. Am Ende steht im Protokoll connection refused, und niemand weiß, welche der neun Verbindungen gemeint ist.

Die Sprache ist an dieser Stelle anders gemeint: Der Fehler ist ein Rückgabewert, und an jeder Ebene wird ihm der Zusammenhang mitgegeben, den nur diese Ebene kennt.

// Jede Ebene ergaenzt, was nur sie weiss. %w behaelt den urspruenglichen
// Fehler, sodass errors.Is und errors.As ihn weiter finden.
func (r *Leser) Kunde(ctx context.Context, id int) (Kunde, error) {
	zeile, err := r.db.QueryRowContext(ctx, abfrage, id).Scan(...)
	if err != nil {
		return Kunde{}, fmt.Errorf("kunde %d lesen: %w", id, err)
	}
	...
}

// Oben steht dann:
// kundenimport: kunde 4711 lesen: dial tcp 10.0.2.7:3306: connection refused

Der zweite Teil davon ist die Unterscheidung, die in PHP die Ausnahmeklasse übernimmt: Ein erwarteter Fehler und ein unerwarteter sind nicht dasselbe. In Go geschieht das über Werte, die man vergleichen kann.

var ErrNichtGefunden = errors.New("nicht gefunden")

// Beim Aufrufer:
if errors.Is(err, ErrNichtGefunden) {
	// Erwartet: 404 statt 500, keine Meldung an die Bereitschaft.
	return antwortNichtGefunden(w)
}

Die Regel, die daraus folgt und die ich für die nützlichste halte: Ein return err ohne Zusatz ist nur dann richtig, wenn die Funktion nichts weiß, was der Aufrufer nicht auch weiß. Das ist selten.

Denkfehler 2: Nebenläufigkeit ist umsonst

PHP-Entwickler kennen Nebenläufigkeit meist als etwas Teures: ein zweiter Prozess, eine Warteschlange, ein Arbeiter. In Go kostet ein nebenläufiger Ablauf eine Zeile, und genau das führt zum zweiten Denkfehler.

// Sieht harmlos aus und ist der haeufigste Fehler im ersten Dienst.
for _, id := range ids {
	go verarbeite(id)
}

Drei Dinge fehlen darin, und jedes einzelne macht den Dienst unzuverlässig. Niemand wartet auf das Ergebnis, also endet das Programm womöglich vorher. Niemand begrenzt die Zahl, also laufen bei zehntausend Einträgen zehntausend Abläufe gegen eine Datenbank mit zwanzig Verbindungen. Und niemand kann abbrechen, wenn die Anfrage längst weg ist.

Die Fassung, die trägt, ist kaum länger:

func verarbeiteAlle(ctx context.Context, ids []int) error {
	gruppe, ctx := errgroup.WithContext(ctx)
	// Begrenzt, und zwar an der knappsten Ressource ausgerichtet:
	// hier den Verbindungen der Datenbank, nicht an den Kernen.
	gruppe.SetLimit(8)

	for _, id := range ids {
		gruppe.Go(func() error {
			return verarbeite(ctx, id)
		})
	}

	// Wartet, bricht bei dem ersten Fehler den Rest ab und gibt ihn zurueck.
	return gruppe.Wait()
}

Der context ist dabei der Teil, für den es in PHP keine Entsprechung gibt und der deshalb am ehesten weggelassen wird. Er trägt die Abbruchbedingung durch den ganzen Aufrufbaum: Bricht der Aufrufer ab, hören alle darunter auf. Ohne ihn arbeitet ein Dienst weiter an Anfragen, die niemand mehr liest, und das fällt erst unter Last auf.

Denkfehler 3: Pakete sind Namensräume

In PHP ist der Namensraum eine Ordnungsidee: Er sagt, wo etwas liegt, und sonst nichts. Ein Team, das das mitbringt, baut in Go eine Verzeichnisstruktur nach Schichten, weil sie im PHP-Projekt auch so aussah.

schlecht/                         besser/
├── models/                       ├── abrechnung/
├── services/                     ├── versand/
├── repositories/                 └── katalog/
└── controllers/

In Go ist ein Paket eine Grenze. Was klein geschrieben ist, ist außerhalb nicht sichtbar, und das ist ein wirksamer Schutz, den die linke Struktur verschenkt: Wenn alle Modelle in einem Paket liegen, ist alles überall sichtbar.

Zwei Folgen davon fallen im ersten Dienst auf.

Zyklische Importe sind ein Fehler beim Übersetzen, keine Warnung. Das wirkt anfangs streng und ist der nützlichste Zwang der Sprache: Wo ein Zyklus entsteht, ist die Aufteilung falsch, und man merkt es sofort statt nach zwei Jahren.

internal/ ist keine Konvention, sondern eine Regel. Was darunter liegt, kann von außerhalb des Moduls nicht importiert werden. Für einen Dienst, der später vielleicht Teile veröffentlicht, ist das die einfachste Art, die eigene Schnittstelle klein zu halten.

Denkfehler 4: Interfaces gehören zur Umsetzung

Der PHP-Reflex: Erst das Interface, dann die Klasse, beide im selben Verzeichnis, und die Klasse sagt implements. In Go gibt es kein implements, und das ist keine Vereinfachung, sondern eine andere Zuordnung.

Ein Interface gehört dorthin, wo es gebraucht wird, nicht dorthin, wo es erfüllt wird. Der Verbraucher beschreibt, was er braucht; wer das erfüllt, weiß nichts davon.

// Im Paket, das den Versand macht. Beschreibt den eigenen Bedarf,
// nicht den Funktionsumfang des Kundendienstes.
type Kundenquelle interface {
	Kunde(ctx context.Context, id int) (Kunde, error)
}

type Versand struct {
	kunden Kundenquelle
}

Der praktische Ertrag zeigt sich beim Testen: Die Attrappe hat eine Methode, nicht vierzehn. Und die Regel dahinter ist kurz genug, um sie sich zu merken: Je größer das Interface, desto wahrscheinlicher liegt es am falschen Ort. Ein Interface mit einer Methode ist in Go normal, nicht unterdimensioniert.

Wie ein Go-Dienst an ein PHP-System andockt, ohne dessen Begriffe zu übernehmen, steht in Einen Anti-Corruption Layer zwischen Go und PHP bauen.

Denkfehler 5: Es fehlt ein Framework

Der fünfte Denkfehler ist der hartnäckigste, weil er nach Produktivität klingt. Er lautet: Für den Anfang suchen wir uns ein Framework, dann müssen wir weniger selbst bauen.

Die Standardbibliothek deckt für einen Dienst dieser Art fast alles ab: HTTP-Server samt Musterrouten, JSON, Datenbankzugriff, Protokollierung mit Struktur, Tests. Was fehlt, sind ein oder zwei Bibliotheken, keine Rahmenordnung.

// Seit Go 1.22 reicht die Standardbibliothek fuer Routen mit Methode
// und Parametern. Ein Framework nur dafuer ist eine Abhaengigkeit,
// die bei jedem Upgrade mitspricht.
mux := http.NewServeMux()
mux.HandleFunc("GET /kunden/{id}", h.kunde)
mux.HandleFunc("POST /kunden", h.anlegen)

Dahinter steht die Eigenschaft, die Go von PHP-Frameworks am deutlichsten unterscheidet: Es gibt keine Magie. Kein Container, der Abhängigkeiten errät, keine Annotationen, die zur Laufzeit etwas verdrahten. Abhängigkeiten werden übergeben, und zwar sichtbar, meist in einer Funktion, die den Dienst zusammensetzt.

Das fühlt sich am ersten Tag nach mehr Arbeit an. Nach sechs Monaten ist es der Grund, warum man den Dienst lesen kann: Alles, was passiert, steht irgendwo als Aufruf.

Was beim ersten eigenen Dienst als Nächstes ansteht, steht in Konfiguration und Secrets für Go-Services.

Was aus PHP gut überträgt

Damit der Eindruck nicht entsteht, es wäre alles anders: Drei Dinge, die ein erfahrenes PHP-Team mitbringt, tragen in Go unmittelbar.

Das Gefühl für Datenzugriff. Wer weiß, wie eine Abfrage in einer Schleife eine Anwendung erledigt, weiß es in Go genauso. Die Sprache ändert daran nichts, und der häufigste Leistungsfehler ist in beiden Sprachen derselbe.

Der Umgang mit Altlasten. Die Erfahrung, dass ein Datenmodell fünfzehn Jahre überlebt, ist in einem neuen Dienst mehr wert als jede Sprachkenntnis. Sie führt dazu, dass man die Grenze zum Altsystem ernst nimmt, statt dessen Struktur zu übernehmen.

Betriebserfahrung. Wer schon einmal um vier Uhr morgens ein Protokoll gelesen hat, schreibt bessere Protokollausgaben. Das ist unabhängig von der Sprache und wichtiger als die Sprache.

Wie ein Go-Dienst konkret neben ein bestehendes PHP-System gesetzt wird, steht in einem eigenen Artikel: Golang in einer PHP-Welt. Und wenn Sie jemanden suchen, der das mit Ihrem Team zusammen macht: die Rollenseite sagt, wie ich arbeite.

Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.