Go-Fehlerbehandlung-Architektur: Grenzen und Muster

Behandle Fehler an der richtigen Grenze.

Inhaltsverzeichnis

Das Fehlerhandling in Go ist leicht kritisierbar. Jeder Go-Entwickler hat diesen Code hunderte Male geschrieben:

if err != nil {
	return err
}

Das ist nicht der interessante Teil. Der interessante Teil ist, was der Fehler bedeutet, wo er behandelt werden sollte, wo er umgewickelt (wrapped) werden sollte, wo er übersetzt werden sollte, wo er protokolliert werden sollte und was an den Aufrufer übergeben werden sollte – das ist die Architekturentscheidung.

Go behandelt Fehler als Werte. Das macht Fehler eindeutig. Es bedeutet aber auch, dass Ihr Codebase ein klares Fehlerhandling-Design benötigt. Ohne eines davon werden Fehler zu zufälligen Strings, HTTP-Handler geben Datenbankdetails preis, Logs doppelten denselben Fehler fünfmal, Retries erfolgen aus den falschen Gründen und Aufrufer prüfen Text statt Verhalten.

Go-Fehlerhandling-Architektur: Fehler, die zwischen Schichten fließen

Dieser Artikel ist keine Einsteiger-Einführung in if err != nil.

Es ist ein praktischer Leitfaden zur Architektur des Go-Fehlerhandlings: Wrapping, Sentinel-Fehler, benutzerdefinierte Fehlertypen, errors.Is, errors.As, Fehlergrenzen, API-Mapping, Logging, Retries, Sicherheit und Produktionsmuster.

Die leicht Meinungsvorurteilte Version: Versuchen Sie nicht, Go-Fehler zum Verschwinden zu bringen. Machen Sie sie an der richtigen Grenze sinnvoll.

Was Go-Fehler sind

In Go ist ein Fehler nur ein Wert, der dieses Interface implementiert:

type error interface {
	Error() string
}

Dieses kleine Interface ist der Grund, warum das Go-Fehlerhandling so direkt wirkt.

Funktionen geben Fehler explizit zurück:

func LoadUser(id string) (*User, error) {
	// ...
}

Aufrufer entscheiden, was zu tun ist:

user, err := LoadUser(id)
if err != nil {
	return nil, err
}

Es gibt keine Exceptions und kein verstecktes Stack-Unwinding. Der Fehlerfall ist Teil der Funktions-signatur.

Das ist gut, aber es bedeutet auch, dass Fehler Design benötigen. Wenn jedes Paket beliebige Nachrichten zurückgibt, können Aufrufer keine zuverlässigen Entscheidungen treffen. Wenn jede Schicht jeden Fehler ohne Disziplin umwickelt, bekommen Operatoren lärmige Nachrichten und Entwickler verwechslungskette. Wenn keine Schicht Fehler umwickelt, verlieren Fehler ihren Kontext.

Das Ziel ist weniger Fehlerhandling, sondern eine bessere Bedeutung von Fehlern.

Die drei Aufgaben eines Fehlers

Ein nützlicher Fehler hat normalerweise eine oder mehrere Aufgaben.

Aufgabe 1: Erklären, was schiefgegangen ist

Für Menschen sollte der Fehler erklären, welche Operation fehlgeschlagen ist.

Beispiel:

return fmt.Errorf("load user %s: %w", id, err)

Das gibt Kontext. Es besagt, dass der Fehler beim Laden eines Benutzers auftrat.

Aufgabe 2: Die Ursache bewahren

Für den Code sollte der Fehler die zugrunde liegende Ursache bewahren, wenn diese Ursache wichtig ist.

Beispiel:

return fmt.Errorf("load user %s: %w", id, err)

%w umwickelt den ursprünglichen Fehler, sodass Aufrufer ihn mit errors.Is oder errors.As untersuchen können. Wrapping hat in Go 1.27 eine neue Falle: Der Vet-Check hinter go test warnt jetzt bei fmt.Errorf("...: %w", p), wenn p ein Zeiger auf einen Typ ist, der selbst error implementiert, weil die zusätzliche Indirektion Typprüfungen und das Matching von errors.Is bricht. Der Go 1.27-Upgrade-Leitfaden deckt dies und die anderen Vet-Ergebnisse ab, die nach einem Toolchain-Bump auftauchen.

Aufgabe 3: Einer Grenze eine Entscheidung ermöglichen

An einer Grenze muss das Programm entscheiden, was zu tun ist.

Beispiele:

  • HTTP 404 zurückgeben
  • HTTP 409 zurückgeben
  • Die Operation wiederholen
  • Auf Warnungsebene protokollieren
  • Eine benutzersichere Nachricht anzeigen
  • Die Transaktion abbrechen
  • Den Fehler an das Monitoring senden
  • Abbruch ignorieren

Diese Entscheidung sollte normalerweise auf der Identität oder dem Typ des Fehlers basieren, nicht auf String-Matching.

Die wichtigsten Fehlerwerkzeuge im modernen Go

Moderner Go bietet Ihnen einen kleinen, aber mächtigen Satz von Werkzeugen.

errors.New

Verwenden Sie errors.New, um einen einfachen Fehlerwert zu erstellen:

var ErrNotFound = errors.New("not found")

Das ist nützlich für Sentinel-Fehler.

fmt.Errorf mit %w

Verwenden Sie fmt.Errorf mit %w, um einen Fehler zu umwickeln:

return fmt.Errorf("query user: %w", err)

Wrapping fügt Kontext hinzu und bewahrt den ursprünglichen Fehler für die Untersuchung.

errors.Is

Verwenden Sie errors.Is, um zu überprüfen, ob ein Fehler ein bestimmtes Ziel irgendwo in seiner Kette trifft:

if errors.Is(err, ErrNotFound) {
	// handle not found
}

Verwenden Sie dies für Sentinel-Fehler und bekannte Bedingungen.

errors.As

Verwenden Sie errors.As, um einen bestimmten Fehlertyp aus einer Kette zu extrahieren:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	// use validationErr.Field or validationErr.Reason
}

Verwenden Sie dies, wenn der Fehler strukturierte Daten trägt.

errors.Join

Verwenden Sie errors.Join, wenn mehrere Fehler auftraten und alle bewahrt werden sollen:

return errors.Join(closeErr, flushErr)

Gefügte Fehler können immer noch mit errors.Is und errors.As untersucht werden.

Verwenden Sie dies vorsichtig. Ein gefügter Fehler bedeutet, dass mehrere Fehler Teil eines Ergebnisses sind.

Sentinel-Fehler

Ein Sentinel-Fehler ist ein paketweiter Fehlerwert, der eine bekannte Bedingung darstellt.

Beispiel:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

Sentinel-Fehler sind nützlich, wenn der Aufrufer nur wissen muss, welche Kategorie von Fehler aufgetreten ist.

Beispiel:

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.queryUser(ctx, id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}
		return nil, fmt.Errorf("query user: %w", err)
	}

	return user, nil
}

Dann kann ein Service oder Handler prüfen:

if errors.Is(err, ErrUserNotFound) {
	// return 404
}

Wann Sentinel-Fehler verwendet werden sollten

Verwenden Sie Sentinel-Fehler, wenn:

  • Die Bedingung stabil ist.
  • Der Aufrufer darauf verzweigen muss.
  • Keine zusätzlichen strukturierten Daten benötigt werden.
  • Der Fehler zu Ihrem Paket oder Ihrer Domäne gehört.

Gute Beispiele:

var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")

Wann Sentinel-Fehler nicht verwendet werden sollten

Erstellen Sie keine Sentinel-Fehler für jede mögliche Fehlerquelle.

Schlecht:

var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")

Wenn Aufrufer darauf nicht verzweigen, könnten sie nur Nachrichten sein.

Seien Sie auch vorsichtig beim Exportieren von zu vielen Sentinel-Fehlern. Exportierte Sentinel-Fehler werden Teil Ihrer Paket-API.

Benutzerdefinierte Fehlertypen

Ein benutzerdefinierter Fehlertyp ist nützlich, wenn der Fehler strukturierte Informationen trägt.

Beispiel:

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}

Aufrufer:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println(validationErr.Field)
}

Das ist besser als das Parsen einer Fehlermeldung.

Wann benutzerdefinierte Fehlertypen verwendet werden sollten

Verwenden Sie benutzerdefinierte Fehlertypen, wenn:

  • Aufrufer strukturierte Daten benötigen.
  • Der Fehler sinnvolle Felder hat.
  • Der Typ Teil Ihres Paketvertrags ist.
  • Der Aufrufer möglicherweise mehrere Werte unterschiedlich behandeln muss.

Beispiele:

  • Validierungsfehler mit Feldnamen
  • Ratenbegrenzungsfehler mit Wiederholungszeit
  • HTTP-Fehler mit Statuscode
  • Parse-Fehler mit Zeile und Spalte
  • Domänenfehler mit Ressourcen-ID

Wann benutzerdefinierte Fehlertypen nicht verwendet werden sollten

Erstellen Sie keine benutzerdefinierten Typen nur, um errors.New zu vermeiden.

Das ist unnötig:

type NotFoundError struct{}

func (e NotFoundError) Error() string {
	return "not found"
}

Wenn es keine nützlichen Daten gibt, reicht oft ein Sentinel.

Fehler-Wrapping

Wrapping fügt einem Fehler Kontext hinzu und bewahrt dabei den ursprünglichen Fehler.

Beispiel:

func LoadConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read config %s: %w", path, err)
	}

	if err := parseConfig(data); err != nil {
		return fmt.Errorf("parse config %s: %w", path, err)
	}

	return nil
}

Wenn os.ReadFile fehlschlägt, erhält der Aufrufer beide:

  • die hochstufige Operation: Konfiguration lesen
  • die niedrigstufige Ursache: Berechtigung verweigert, Datei nicht gefunden usw.

Beide sind über die Fehlerkette verfügbar, was das konsequente Wrapping mit %w wertvoll macht.

Mit nützlichem Kontext wrappen

Gutes Wrapping sagt, welche Operation fehlgeschlagen ist:

return fmt.Errorf("create invoice %s: %w", invoiceID, err)

Schlechtes Wrapping fügt Lärm hinzu:

return fmt.Errorf("error: %w", err)

Das sagt dem Aufrufer nichts.

Vermeiden Sie auch, dasselbe Substantiv in jeder Schicht zu wiederholen:

return fmt.Errorf("user service: get user: user repository: query user: %w", err)

Diese Art von Kette ist technisch korrekt und praktisch nervig.

Wickeln Sie dort ein, wo der Kontext die Bedeutung ändert. Wenn Sie nicht in einem Satz erklären können, welche Operation fehlgeschlagen ist, wickeln Sie wahrscheinlich entweder zu aggressiv oder nicht genug ein.

Wann eingewickelt und wann nicht eingewickelt werden sollte

Dies ist eine der wichtigsten Architekturentscheidungen.

Einwickeln, wenn eine bedeutungsvolle Grenze überschritten wird

Wickeln Sie ein, wenn der Fehler von einer Operation zu einer höherstufigen Operation wandert.

Beispiel:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

Der Repository-Fehler ist nun Teil einer Service-Operation, und dieser hinzugefügte Kontext ist nützlich, wenn Operatoren einen Fehler durch die Logs zurückverfolgen.

Nicht nur zum Sagen von “fehlgeschlagen” einwickeln

Schlecht:

if err != nil {
	return fmt.Errorf("failed: %w", err)
}

Das Wort “fehlgeschlagen” ist normalerweise durch die Existenz eines Fehlers impliziert.

Nicht einwickeln, wenn Sie übersetzen

Manchmal sollten Sie einen Fehler in einen Domänenfehler übersetzen.

Beispiel:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

Dies verbirgt die Datenbankdetails absichtlich und zeigt eine Domänenbedingung an.

Sie können die Ursache immer noch bewahren, wenn es nützlich ist, aber tun Sie es bewusst.

Keine Implementierungsdetails versehentlich preisgeben

Wenn Sie einen niedrigstufigen Fehler mit %w umwickeln, können Aufrufer ihn untersuchen.

Das ist normalerweise gut innerhalb Ihrer Anwendung.

Aber in einer öffentlichen Paket-API kann Wrapping Implementierungsdetails als Teil Ihres Vertrags preisgeben.

Zum Beispiel, wenn Ihr Paket sql.ErrNoRows umwickelt, könnten Aufrufer beginnen, sich darauf zu verlassen:

if errors.Is(err, sql.ErrNoRows) {
	// caller now knows you use database/sql
}

Wenn Sie die Speicherung später ändern könnten, bevorzugen Sie einen Domänen-Sentinel:

var ErrUserNotFound = errors.New("user not found")

Und geben Sie diesen dann von der Paketgrenze aus zurück.

Fehlergrenzen

Der nützlichste Weg, über das Go-Fehlerhandling nachzudenken, ist über Grenzen.

Eine Grenze ist ein Ort, an dem ein Fehler seine Bedeutung oder sein Publikum ändert.

Gemeinsame Grenzen umfassen:

  • Datenbank zu Repository
  • Repository zu Service
  • Service zu HTTP-Handler
  • Service zu CLI-Befehl
  • interner Fehler zu benutzergewandter Nachricht
  • transienter Fehler zu Retry-Entscheidung
  • Operationsfehler zu Log-Event
  • Domänenfehler zu API-Antwort

Fehlerarchitektur ist größtenteils Grenzdesign. Jede Grenze ist ein Entscheidungspunkt, an dem Fehler entweder Kontext gewinnen, Implementierungsdetails verlieren oder in eine Form übersetzt werden, mit der die nächste Schicht handeln kann.

Repository-Grenze

Das Repository spricht mit der Speicherung.

Es sollte normalerweise datenspeicherspezifische Fehler in Domänenfehler übersetzen.

Beispiel:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

Das Repository verbirgt sql.ErrNoRows und zeigt ErrUserNotFound an – eine saubere Grenze, die bedeutet, dass der Service nichts über die Repräsentation von „nicht gefunden" in der Speicherung wissen muss.

Service-Grenze

Der Service besitzt die Geschäftsbedeutung.

Er sollte normalerweise Operationskontext hinzufügen und Domänenfehler bewahren.

Beispiel:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

Dies bewahrt die Domänenbedingung, während es Kontext für unerwartete Fehler hinzufügt.

Für komplexere Geschäftsregeln kann der Service Domänenfehler direkt erstellen:

var ErrAccountDisabled = errors.New("account disabled")

func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		return nil, fmt.Errorf("get user by email: %w", err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	// ...
	return session, nil
}

Der Service ist der richtige Ort für geschäftsbezogene Fehler – direkt aus Domänenlogik erstellt und nicht aus Infrastrukturbedingungen übersetzt.

HTTP-Handler-Grenze

Der HTTP-Handler übersetzt Anwendungsfehler in HTTP-Antworten.

Dies ist eine Grenze, an der interne Details zu benutzersicheren Antworten werden sollten.

Web-Frameworks unterscheiden sich in der Struktur dieser Übersetzung. Echo leitet Handler-Fehler über einen einzelnen HTTPErrorHandler weiter, während Gin-Handler Fehler direkt auf dem Request-Kontext rendern – die Abwägungen zwischen den beiden Stilen werden in Go-Web-Frameworks 2026: net/http, chi, Gin, Echo, Fiber im Vergleich verglichen.

Beispiel:

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		user, err := svc.GetUser(r.Context(), r.PathValue("id"))
		if err != nil {
			writeHTTPError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

Fehler-Mapping:

func writeHTTPError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, ErrUserNotFound):
		http.Error(w, "user not found", http.StatusNotFound)

	case errors.Is(err, ErrAccountDisabled):
		http.Error(w, "account disabled", http.StatusForbidden)

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
	}
}

Der Handler mappt Domänenfehler auf HTTP-Semantiken, statt rohe Datenbank- oder interne Fehlerdetails preiszugeben. Hier machen viele Go-Anwendungen Fehler – sie geben entweder zu viele interne Details preis oder fassen alle Fehler in HTTP 500 zusammen. Für ein vollständiges Bild von Handler-Mustern und Middleware in Go-APIs deckt REST-APIs in Go bauen Authentifizierung, Routing und Fehlerhandling über die Standard-Bibliothek, Gin, Echo und Fiber ab.

CLI-Grenze

Eine CLI hat eine andere Grenze als eine HTTP-API.

In einer CLI sollte der Fehler für die Person, die den Befehl ausführt, nützlich sein.

Beispiel:

func RunImport(ctx context.Context, args []string) error {
	if len(args) == 0 {
		return ErrMissingInputFile
	}

	if err := importFile(ctx, args[0]); err != nil {
		return fmt.Errorf("import %s: %w", args[0], err)
	}

	return nil
}

An der Befehlsgrenze:

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, formatCLIError(err))
		os.Exit(exitCode(err))
	}
}

Bekannte Fehler auf Exit-Codes mappen:

func exitCode(err error) int {
	switch {
	case errors.Is(err, ErrMissingInputFile):
		return 2
	case errors.Is(err, ErrValidation):
		return 3
	default:
		return 1
	}
}

Eine CLI kann oft mehr Details anzeigen als eine öffentliche API, aber sie sollte immer noch vermeiden, Secrets preiszugeben.

API-Fehlertyp-Muster

Für HTTP-APIs kann ein kleiner, auf Anwendungsebene basierender Fehlertyp nützlich sein.

Beispiel:

type APIError struct {
	Status  int
	Code    string
	Message string
	Err     error
}

func (e *APIError) Error() string {
	if e.Err == nil {
		return e.Message
	}

	return e.Message + ": " + e.Err.Error()
}

func (e *APIError) Unwrap() error {
	return e.Err
}

Konstruktor:

func NewAPIError(status int, code string, message string, err error) *APIError {
	return &APIError{
		Status:  status,
		Code:    code,
		Message: message,
		Err:     err,
	}
}

Verwendung:

return NewAPIError(
	http.StatusConflict,
	"duplicate_email",
	"email is already registered",
	ErrDuplicateEmail,
)

Handler:

func writeAPIError(w http.ResponseWriter, err error) {
	var apiErr *APIError
	if errors.As(err, &apiErr) {
		writeJSON(w, apiErr.Status, map[string]string{
			"code":    apiErr.Code,
			"message": apiErr.Message,
		})
		return
	}

	writeJSON(w, http.StatusInternalServerError, map[string]string{
		"code":    "internal_error",
		"message": "internal server error",
	})
}

Dieses Muster ist nützlich, wenn Sie strukturierte API-Fehler mit stabilen Codes wünschen.

Verwenden Sie es an der API-Grenze. Erzwingen Sie nicht jedem internen Paket, API-spezifische Fehler zurückzugeben.

Domänenfehler vs. Transportfehler

Halten Sie Domänenfehler von Transportfehlern getrennt.

Domänenfehler:

var ErrInsufficientBalance = errors.New("insufficient balance")

Transport-Mapping:

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "insufficient balance", http.StatusConflict)
	return
}

Lassen Sie Ihre Domänenschicht keine HTTP-Statuscodes zurückgeben:

return &APIError{Status: http.StatusConflict}

Das koppelt Geschäftslogik an HTTP und verhindert, dass Ihre Service-Schicht sauber über HTTP, CLI, Worker, Tests und zukünftige gRPC-Adapter funktioniert. Transport-Mapping gehört an die Transportgrenze, nicht in den Domänencode. Für Hinweise, wo man Domänenfehler, Sentinel-Fehler und Transportadapter innerhalb Ihrer Projektstruktur definiert, deckt Go-Projektstruktur: Praktiken & Muster die internal/, pkg/- und Adapter-Konventionen ab, die diese Schichten sauber getrennt halten.

Wiederholbare Fehler

Einige Fehler sollten einen Retry auslösen. Andere sollten es nicht.

Entscheiden Sie dies nicht durch String-Matching.

Verwenden Sie ein Marker-Interface oder eine explizite Funktion.

Beispiel:

type RetryableError struct {
	Err error
}

func (e *RetryableError) Error() string {
	return e.Err.Error()
}

func (e *RetryableError) Unwrap() error {
	return e.Err
}

Helfer:

func Retryable(err error) error {
	if err == nil {
		return nil
	}

	return &RetryableError{Err: err}
}

func IsRetryable(err error) bool {
	var retryable *RetryableError
	return errors.As(err, &retryable)
}

Verwendung:

if err := callRemoteAPI(ctx); err != nil {
	if isTemporaryNetworkError(err) {
		return Retryable(fmt.Errorf("call remote api: %w", err))
	}

	return fmt.Errorf("call remote api: %w", err)
}

Retry-Schleife:

err := doWork(ctx)
if err != nil {
	if IsRetryable(err) {
		// retry with backoff
	}
	return err
}

Das ist viel besser als zu prüfen, ob die Fehlermeldung „timeout" enthält – String-Matching bricht still, wenn sich Nachrichten ändern, und schafft unsichtbare Kopplung zwischen Produzent und Konsument.

Validierungsfehler

Validierungsfehler benötigen oft strukturierte Daten.

Beispiel:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

func (e *ValidationError) Error() string {
	return "validation failed"
}

Verwendung:

func ValidateCreateUser(req CreateUserRequest) error {
	var fields []FieldError

	if req.Email == "" {
		fields = append(fields, FieldError{
			Field:   "email",
			Message: "email is required",
		})
	}

	if len(fields) > 0 {
		return &ValidationError{Fields: fields}
	}

	return nil
}

Handler:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	writeJSON(w, http.StatusBadRequest, validationErr)
	return
}

Dies ist eine gute Verwendung von errors.As, da der Aufrufer strukturierte Informationen benötigt – Feldnamen und Validierungsnachrichten – nicht nur eine intransparante Fehlermeldung.

Mehrere Fehler

Manchmal schlagen mehrere Dinge fehl.

Beispiele:

  • Schließen mehrerer Ressourcen
  • Validierung vieler Felder
  • Herunterfahren mehrerer Worker
  • Ausführen unabhängiger Prüfungen
  • Flushen und Schließen der Ausgabe

Verwenden Sie errors.Join, wenn alle Fehler bewahrt werden sollen.

Beispiel:

func CloseAll(closers ...io.Closer) error {
	var errs []error

	for _, closer := range closers {
		if err := closer.Close(); err != nil {
			errs = append(errs, err)
		}
	}

	return errors.Join(errs...)
}

Aufrufer:

if err := CloseAll(a, b, c); err != nil {
	return fmt.Errorf("close resources: %w", err)
}

Sowohl errors.Is als auch errors.As können gefügte Fehler untersuchen, was bedeutet, dass gefügte Fehlerwerte vollständig kompatibel mit standardmäßigen Fehlerprüfungsmustern bleiben.

Wann errors.Join nicht verwendet werden sollte

Verwenden Sie errors.Join nicht, wenn es einen primären Fehler und etwas Log-Kontext gibt.

Verwenden Sie es nicht, um die Entscheidung zu vermeiden, welcher Fehler wichtig ist.

Geben Sie keine großen gefügten Fehler an Benutzer zurück.

Gefügte Fehler sind nützlich, aber sie können schnell lärmig werden.

Panic ist kein Fehlerhandling

In normalem Anwendungscode, verwenden Sie Panic nicht für erwartete Fehler.

Schlecht:

if err != nil {
	panic(err)
}

Verwenden Sie Panic für Programmierfehler oder wirklich nicht wiederherstellbare Situationen.

Beispiele:

  • unmögliche interne Invariantenverletzung
  • ungültige Paketinitialisierung
  • Test-Helfer-Fehler mit t.Fatal oder Panic in beschränkten Fällen
  • nicht wiederherstellbarer Startkonfigurationsfehler, je nach Stil

Panicen Sie nicht, weil eine Datenbankabfrage fehlgeschlagen ist oder ein Benutzer ungültige Eingaben übermittelt hat.

Das sind normale Fehler.

Fehler-Logging

Ein häufiger Go-Fehler ist es, denselben Fehler in jeder Schicht zu protokollieren.

Schlecht:

func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.query(ctx, id)
	if err != nil {
		log.Printf("query failed: %v", err)
		return nil, err
	}
	return user, nil
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		log.Printf("service failed: %v", err)
		return nil, err
	}
	return user, nil
}

Dies erzeugt doppelte Logs für einen Fehler.

Besser:

  • Fehler beim Aufstieg umwickeln
  • einmal an der Grenze protokollieren, an der der Fehler behandelt wird
  • strukturierten Kontext im Log einbeziehen

Beispiel:

func (s *Server) handleError(r *http.Request, err error) {
	s.logger.ErrorContext(
		r.Context(),
		"request failed",
		"method", r.Method,
		"path", r.URL.Path,
		"err", err,
	)
}

Dies gibt ein Log-Event mit der gesamten Fehlerkette. Für eine produktionsreife strukturierte Logging-Einrichtung deckt Strukturiertes Logging in Go mit slog log/slog-Records, JSON-Handler, Kontext-Korrelation und Redigierung ab – all dies passt natürlich zu grenznahem Fehler-Logging.

Wann in unteren Schichten protokolliert werden sollte

Protokollieren Sie in unteren Schichten nur, wenn die Schicht den Fehler tatsächlich behandelt oder wichtigen operativen Kontext hinzufügt, der woanders nicht sichtbar sein wird.

Zum Beispiel kann eine Retry-Schleife jeden Retry-Versuch auf Debug- oder Warnungsebene protokollieren.

Aber ein Repository sollte nicht jede Abfragefehler protokollieren, wenn der Handler den finalen Request-Fehler protokolliert.

Benutzergewandte Fehler vs. Operatoren-Fehler

Zeigen Sie interne Fehler nicht direkt Benutzern an.

Interner Fehler:

query user by id: dial tcp 10.0.4.12:5432: connection refused

Benutzergewandte Nachricht:

internal server error

Operatoren-Log:

request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"

Das sind verschiedene Zielgruppen, und eine gute Fehlerarchitektur hält sie getrennt:

  • interner diagnostischer Fehler
  • benutzersichere Antwort
  • stabiler API-Fehlercode
  • Operatoren-Log-Kontext

Das Erzwingen einer einzelnen Fehlermeldung für all diese Zielgruppen erzeugt entweder ein Risiko der Preisgabe oder einen Fehlersuch-Albtraum. Gestalten Sie Ihre Fehlerarchitektur um verschiedene Werte für verschiedene Konsumenten.

Sicheres Fehlerhandling

Fehler können sensible Informationen preisgeben.

Vermeiden Sie die Preisgabe von:

  • Datenbankverbinungsstrings
  • SQL-Abfragen mit Secrets
  • internen Hostnamen
  • Dateipfaden
  • Zugangs-Tokens
  • API-Keys
  • Stack-Traces
  • privaten Kundendaten
  • Autorisierungspolicy-Details

Das ist besonders in HTTP-APIs wichtig.

Schlecht:

http.Error(w, err.Error(), http.StatusInternalServerError)

Gut:

http.Error(w, "internal server error", http.StatusInternalServerError)

Protokollieren Sie den internen Fehler sicher für Operatoren. Geben Sie eine sichere Nachricht an den Benutzer zurück.

Fehlercodes

Für öffentliche APIs sind stabile Fehlercodes oft besser als sich nur auf Nachrichten zu verlassen.

Beispiel-Antwort:

{
  "code": "user_not_found",
  "message": "user not found"
}

Die Nachricht kann sich ändern. Der Code sollte stabil sein.

Verwenden Sie Fehlercodes für:

  • Client-Verhalten
  • Dokumentation
  • SDKs
  • Lokalisierung
  • Support-Diagnosen

Lassen Sie Clients keine englischen Fehlermeldungen parsen.

Ein praktisches geschichtetes Fehlerdesign

Hier ist ein sauberes Muster für viele Go-Backend-Dienste.

Repository-Schicht

  • Spricht mit Datenbank oder externer Speicherung.
  • Wandelt speicherspezifische Nicht-Gefunden-Fehler in Domänenfehler um.
  • Wickelt unerwartete Speicherfehler mit Operationskontext ein.
  • Gibt keine HTTP-Fehler zurück.
  • Protokolliert normalerweise nicht.

Beispiel:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

return nil, fmt.Errorf("query user by id: %w", err)

Service-Schicht

  • Besitzt Geschäftsregeln.
  • Erstellt Domänenfehler.
  • Bewahrt bekannte Domänenfehler.
  • Wickelt unerwartete Fehler unterer Ebenen ein.
  • Gibt keine HTTP-Statuscodes zurück.
  • Protokolliert normalerweise nicht.

Beispiel:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Transport-Schicht

  • Mappt Domänenfehler auf HTTP-, gRPC- oder CLI-Antworten.
  • Protokolliert nicht behandelte oder unerwartete Fehler.
  • Verbirgt interne Details vor Benutzern.
  • Setzt Statuscodes und API-Fehlercodes.

Beispiel:

switch {
case errors.Is(err, ErrUserNotFound):
	writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
	writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}

Diese Trennung hält das Fehlerhandling verständlich und lässt jede Schicht unabhängig weiterentwickeln – Sie können die Speichertechnologie ändern, ohne die Servicelogik oder die Transport-Mapping zu berühren. Das geschichtete Design funktioniert am besten, wenn Abhängigkeiten injiziert und nicht hartkodiert werden; Dependency Injection in Go: Muster & Best Practices deckt die Konstruktoren- und Interface-Muster ab, die jede Grenze einfach separat testbar machen.

Komplettes Beispiel

Hier ist ein kleines End-to-End-Beispiel.

Domänenfehler:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("user not found")
	ErrDuplicateEmail = errors.New("duplicate email")
	ErrAccountDisabled = errors.New("account disabled")
)

Repository:

package users

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

type Repository struct {
	db *sql.DB
}

func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name, disabled
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
		&user.Disabled,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

Service:

package users

import (
	"context"
	"errors"
	"fmt"
)

type Service struct {
	repo *Repository
}

func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get profile for user %s: %w", id, err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	return &Profile{
		ID:    user.ID,
		Email: user.Email,
		Name:  user.Name,
	}, nil
}

HTTP-Handler:

package httpapi

import (
	"context"
	"errors"
	"net/http"

	"example.com/app/users"
)

type Handler struct {
	users *users.Service
}

func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
	profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
	if err != nil {
		h.writeError(w, err)
		return
	}

	writeJSON(w, http.StatusOK, profile)
}

func (h *Handler) writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, users.ErrUserNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{
			"code":    "user_not_found",
			"message": "user not found",
		})

	case errors.Is(err, users.ErrAccountDisabled):
		writeJSON(w, http.StatusForbidden, map[string]string{
			"code":    "account_disabled",
			"message": "account is disabled",
		})

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		writeJSON(w, http.StatusGatewayTimeout, map[string]string{
			"code":    "request_timeout",
			"message": "request timed out",
		})

	default:
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"code":    "internal_error",
			"message": "internal server error",
		})
	}
}

Diese Struktur gibt Ihnen:

  • Domänenfehler
  • Speicher-Übersetzung
  • Service-Kontext
  • sichere HTTP-Mapping
  • untersuchbare Fehlerketten
  • kein String-Matching
  • keine Transport-Preisegabe in Domänencode

Das ist die Art von Fehlerarchitektur, die skaliert – einfach genug für einen neuen Mitarbeiter zu verstehen, aber strukturiert genug, dass Domänenlogik nie in Transport-Antworten durchsickert.

Testen des Fehlerverhaltens

Fehlerverhalten sollte genauso gründlich getestet werden wie der Happy Path, da Grenzentcheidungen – Sentinel-Mapping, Typ-Extraktion, HTTP-Codes – oft die Stelle sind, an der sich Bug am längsten verstecken. Für einen vollständigen Leitfaden zur Go-Teststruktur, Mocking und Abdeckungsmuster, siehe Go-Unit-Testing: Struktur & Best Practices.

Testen des Sentinel-Mappings

func TestGetByIDNotFound(t *testing.T) {
	repo := newTestRepository(t)

	_, err := repo.GetByID(t.Context(), "missing")
	if !errors.Is(err, users.ErrUserNotFound) {
		t.Fatalf("got %v, want ErrUserNotFound", err)
	}
}

Testen der benutzerdefinierten Fehler-Extraktion

func TestValidationError(t *testing.T) {
	err := ValidateCreateUser(CreateUserRequest{})

	var validationErr *ValidationError
	if !errors.As(err, &validationErr) {
		t.Fatalf("got %T, want ValidationError", err)
	}

	if len(validationErr.Fields) == 0 {
		t.Fatal("expected validation fields")
	}
}

Testen des HTTP-Mappings

func TestWriteErrorNotFound(t *testing.T) {
	rec := httptest.NewRecorder()

	writeHTTPError(rec, users.ErrUserNotFound)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
	}
}

Tests sollten beweisen, dass bekannte Fehler das richtige Verhalten an jeder Grenze erzeugen, damit das Refactorings von Speicher- oder Transport-Schichten den Fehlervertrag nicht stillschweigend ändern kann.

Häufige Anti-Patterns

Anti-Pattern 1: String-Matching

Schlecht:

if strings.Contains(err.Error(), "not found") {
	// ...
}

Verwenden Sie stattdessen errors.Is oder errors.As – beide behandeln umwickelte Fehlerketten automatisch und brechen nicht, wenn Nachrichten neu formatiert oder lokalisiert werden.

Anti-Pattern 2: Verlust der Ursache

Schlecht:

return errors.New("query failed")

Besser:

return fmt.Errorf("query user: %w", err)

Anti-Pattern 3: Wrapping ohne Bedeutung

Schlecht:

return fmt.Errorf("error happened: %w", err)

Wickeln Sie mit Operationskontext ein, der erklärt, was versucht wurde, wie "create invoice %s: %w" statt eines vagen Präfixes, das keinen diagnostischen Wert hinzufügt.

Anti-Pattern 4: Logging in jeder Schicht

Schlecht:

log.Println(err)
return err

auf jeder Ebene. Protokollieren Sie einmal dort, wo der Fehler schließlich behandelt wird, nicht in jeder Zwischenschicht, die ihn nur weiter nach oben leitet.

Anti-Pattern 5: Rückgabe von HTTP-Fehlern aus Domänencode

Schlecht:

return &APIError{Status: http.StatusNotFound}

aus einem Domänen-Service. Mappen Sie Domänenfehler auf HTTP-Statuscodes und Antwortkörper an der Handler-Grenze und halten Sie Ihre Service-Schicht unabhängig von Transport-Sorgen.

Anti-Pattern 6: Preisgabe interner Fehler an Benutzer

Schlecht:

http.Error(w, err.Error(), http.StatusInternalServerError)

Geben Sie sichere, generische Nachrichten an Benutzer zurück und protokollieren Sie den vollständigen internen Fehler mit strukturiertem Kontext für Operatoren. Geben Sie niemals Datenbankverbinungsstrings, Dateipfade oder rohe Stack-Traces in API-Antworten preis.

Anti-Pattern 7: Zu viele exportierte Sentinel-Fehler

Exportierte Fehler sind Teil Ihrer Paket-API, und deren Hinzufügen verpflichtet Sie zu deren Wartung. Exportieren Sie nicht jede interne Bedingung, außer externe Aufrufer müssen wirklich darauf verzweigen – bevorzugen Sie das Halten von Sentinel-Fehlern unexportiert, bis ein klarer Bedarf besteht.

Anti-Pattern 8: Verwendung von Panic für erwartete Fehler

Schlecht:

panic(err)

für normale Laufzeitfehler. Reservieren Sie Panic für wirklich nicht wiederherstellbare Bedingungen oder Programmierfehler, nicht für fehlende Datensätze oder ungültige Benutzereingaben – geben Sie in diesen Fällen immer Fehler zurück.

Anti-Pattern 9: Ignorieren von Context-Fehlern

Schlecht:

return fmt.Errorf("request failed")

wenn die eigentliche Ursache context.Canceled war. Bewahren Sie Context-Fehler auf, damit Aufrufer zwischen einem echten Operationsfehler und einer abgebrochenen oder zeitlich überschrittenen Anfrage unterscheiden können und entsprechend auf jede reagieren. Für eine umfassende Behandlung dessen, wie Context-Abbruch und Timeout-Propagation über Service-Schichten hinweg funktionieren, siehe Go context.Context richtig machen.

Fehler-Review-Checkliste

Verwenden Sie diese Checkliste in der Code-Review.

Fehlererstellung

  • Ist dies eine bekannte Bedingung?
  • Sollte es ein Sentinel sein?
  • Braucht es strukturierte Daten?
  • Sollte es ein benutzerdefinierter Typ sein?
  • Ist die Fehlermeldung klar?

Fehler-Wrapping

  • Fügt das Wrapping nützlichen Operationskontext hinzu?
  • Bewahrt %w die Ursache, wo nötig?
  • Gibt der Code versehentlich Implementierungsdetails preis?
  • Ist die Kette zu lärmig?

Fehler-Übersetzung

  • Wird ein niedrigstufiger Fehler an der richtigen Grenze übersetzt?
  • Wird datenbankspezifisches Verhalten von Service-Code verborgen?
  • Sind Domänenfehler unabhängig von HTTP- oder CLI-Sorgen?

Fehlerbehandlung

  • Verzweigt der Aufrufer mit errors.Is oder errors.As?
  • Werden Context-Abbruch und Deadlines korrekt behandelt?
  • Werden wiederholbare Fehler explizit identifiziert?
  • Sind Validierungsfehler strukturiert?

Logging

  • Wird der Fehler einmal, an der Behandlungsgrenze, protokolliert?
  • Sind Logs strukturiert?
  • Sind sensible Details aus Benutzernantworten ausgeschlossen?
  • Gibt es genug Kontext für Operatoren?

Testing

  • Werden bekannte Fehlerfälle getestet?
  • Werden HTTP- oder CLI-Mappings getestet?
  • Werden Validierungsdetails getestet?
  • Werden Retry-Entscheidungen getestet?

Meine meinungsstarken Regeln

Regel 1: Fehler sollten Grenzen mit Bedeutung überschreiten

Lassen Sie Fehler nicht einfach herumreichen. Entscheiden Sie, was sie in jeder Schicht bedeuten.

Regel 2: Wrappen für Kontext, nicht für Dekoration

Wenn Wrapping keine nützlichen Informationen darüber hinzufügt, welche Operation fehlgeschlagen ist, wickeln Sie nicht ein. Eine zusätzliche Schicht Kontext ohne Bedeutung macht die Fehlerkette schwerer lesbar und fügt keinen diagnostischen Wert hinzu.

Regel 3: Implementierungsfehler in Domänenfehler übersetzen

Lassen Sie sql.ErrNoRows nicht Teil Ihrer Geschäftslogik werden. Übersetzen Sie Implementierungsfehler in Domänenfehler an der Speicher-Grenze, damit der Rest der Anwendung nie wissen muss, welche Datenbank oder ORM darunter liegt.

Regel 4: Fehlermeldungen nicht parsen

Wenn Code nach Fehlertyp verzweigen muss, verwenden Sie Sentinel, benutzerdefinierte Typen, errors.Is oder errors.As. String-Inspektion schafft unsichtbare Kopplung, die still bricht, wenn sich Fehlermeldungen ändern.

Regel 5: Nur einmal loggen

Wickeln Sie Fehler beim Aufstieg ein. Protokollieren Sie dort, wo der Fehler schließlich behandelt wird.

Regel 6: Benutzernachrichten sicher halten

Interne diagnostische Fehler sind für Logs. Benutzergewandte Nachrichten sind für Benutzer.

Regel 7: Transportfehler an der Transportgrenze halten

HTTP-Statuscodes gehören in Handler oder API-Adapter, nicht in Domänen-Service. Domänencode sollte über Transporte wiederverwendbar sein – heute HTTP, morgen CLI, gRPC oder ein ereignisgesteuerter Worker.

Schlussgedanken

Go-Fehlerhandling geht nicht darum, ewig if err != nil zu schreiben – es geht darum, Fehler an jeder Grenze explizit und verständlich zu machen.

Die Mechanik ist einfach:

return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter

Die Architektur ist der schwierigere Teil:

translate at boundaries
preserve causes
hide internals from users
log once
test known failures

Das ist gut gemachtes Go-Fehlerhandling – nicht clever, nicht magisch, aber klar genug, dass der nächste Entwickler, Operator, API-Client und das zukünftige Ich verstehen können, was fehlgeschlagen ist und was als Nächstes geschehen sollte. Für einen breiteren Blick auf Produktions-Go-Muster über Integration, Testing und Datenzugriff hinweg, siehe App-Architektur in Produktion.

Quellen

Abonnieren

Neue Beiträge zu Systemen, Infrastruktur und KI-Engineering.