Go-Fehlerbehandlung: Architekturgrenzen und -muster

Behandle Fehler an der richtigen Grenze.

Inhaltsverzeichnis

Die Fehlerbehandlung in Go ist ein beliebtes Ärgernis. Jeder Go-Entwickler hat diesen Code hundertmal 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 eingebettet (wrapped) werden sollte, wo er übersetzt werden sollte, wo er geloggt werden sollte und was dem Aufrufer (Caller) offengelegt werden sollte – das ist die Architekturfrage.

Go behandelt Fehler als Werte. Das macht Fehler explizit. Es bedeutet auch, dass Ihr Codebestand eine klare Fehlerbehandlungsarchitektur benötigt. Ohne eine solche werden Fehler zu willkürlichen Strings, HTTP-Handler geben Details zur Datenbank preis, Logs doppelten denselben Fehlerschlager fünfmal, Wiederholungen (Retries) finden aus den falschen Gründen statt und Aufrufer untersuchen Text statt Verhalten.

Fehlerbehandlungsarchitektur in Go: Fehler fließen zwischen Schichten hin und her

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

Es ist ein praktischer Leitfaden zur Fehlerbehandlungsarchitektur in Go: Wrapping, Sentinel-Fehler, benutzerdefinierte Fehlertypen, errors.Is, errors.As, Fehlergrenzen (Error Boundaries), API-Mapping, Logging, Retries, Sicherheit und Produktionsmuster.

Die leicht meine Meinung reflektierende 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 einfach ein Wert, der dieses Interface implementiert:

type error interface {
	Error() string
}

Dieses kleine Interface ist der Grund, warum die Fehlerbehandlung in Go 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 verborgenes Stack-Unwinding. Fehler sind Teil der FunktionsSignatur.

Das ist gut, bedeutet aber auch, dass Fehler Design benötigen. Wenn jedes Paket beliebige Nachrichten zurückgibt, können Aufrufer keine verlässlichen Entscheidungen treffen. Wenn jede Schicht jeden Fehler ohne Disziplin einbettet, erhalten Betreiber (Operators) unklare Nachrichten und Entwickler verwirrende Ketten. Wenn keine Schicht Fehler einbettet, verlieren Fehler ihren Kontext.

Das Ziel ist weniger Fehlerbehandlung, sondern eine bessere Fehlerbedeutung.

Die drei Aufgaben eines Fehlers

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

Aufgabe 1: Erklären, was fehlgeschlagen 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 sagt, dass der Fehler beim Laden eines Users aufgetreten ist.

Aufgabe 2: Die Ursache bewahren

Für 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 bettet den ursprünglichen Fehler ein, damit Aufrufer ihn mit errors.Is oder errors.As untersuchen können.

Aufgabe 3: Eine Grenze treffen lassen

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

Beispiele:

  • HTTP 404 zurückgeben
  • HTTP 409 zurückgeben
  • Die Operation wiederholen
  • Auf Warnstufe loggen
  • Eine nutzerfreundliche Nachricht anzeigen
  • Die Transaktion abbrechen
  • Den Fehler zum Monitoring senden
  • Abbruch ignorieren

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

Die wichtigsten Fehlerwerkzeuge im modernen Go

Modernes 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 einzubetten:

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 prüfen, ob ein Fehler in seiner Kette irgendwo mit einem spezifischen Ziel übereinstimmt:

if errors.Is(err, ErrNotFound) {
	// nicht gefunden behandeln
}

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

errors.As

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

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

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

errors.Join

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

return errors.Join(closeErr, flushErr)

Verbundene (Joined) Fehler können immer noch mit errors.Is und errors.As untersucht werden.

Verwenden Sie dies mit Bedacht. Ein verbundener Fehler bedeutet, dass mehrere Fehlschläge Teil eines Ergebnisses sind.

Sentinel-Fehler

Ein Sentinel-Fehler ist ein paketweiser 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) {
	// 404 zurückgeben
}

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 Sentinels für jeden möglichen Fehlschlag.

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, sind sie möglicherweise nur Nachrichten.

Seien Sie auch vorsichtig mit dem Exportieren zu vieler Sentinel-Fehler. 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 eines Fehlerstrings.

Wann benutzerdefinierte Fehlertypen verwendet werden sollten

Verwenden Sie benutzerdefinierte Fehlertypen, wenn:

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

Beispiele:

  • Validierungsfehler mit Feldnamen
  • Rate-Limit-Fehler 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.

Dies 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-Einbettung (Wrapping)

Wrapping fügt einem Fehler Kontext hinzu und bewahrt 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 beides:

  • die hochlevelige Operation: Config lesen
  • die tieflevelige Ursache: Berechtigung verweigert, Datei nicht gefunden, etc.

Beides ist über die Fehlerkette verfügbar, was das konsequente Wrapping mit %w lohnenswert macht.

Mit nützlichem Kontext einbetten

Gutes Wrapping sagt, welche Operation fehlgeschlagen ist:

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

Schlechtes Wrapping fügt Rauschen 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, aber praktisch ärgerlich.

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

Wann einbetten und wann nicht einbetten

Das ist eine der wichtigsten Architekturdécisionen.

Einbetten, wenn eine aussagekräftige Grenze überschritten wird

Betten Sie ein, wenn der Fehler von einer Operation zu einer höherleveligen Operation wechselt.

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 jetzt Teil einer Service-Operation, und dieser zusätzliche Kontext ist nützlich, wenn Betreiber einen Fehler in den Logs zurückverfolgen.

Nicht einbetten nur, um „fehlgeschlagen“ zu sagen

Schlecht:

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

Das Wort „failed“ (fehlgeschlagen) ist normalerweise implizit durch die Tatsache, dass ein Fehler existiert.

Nicht einbetten, wenn Sie übersetzen

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

Beispiel:

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

Das verbirgt absichtlich die Datenbank-Details und zeigt eine Domänenbedingung.

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

Keine Implementierungsdetails versehentlich preisgeben

Wenn Sie einen tiefleveligen Fehler mit %w einbetten, 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 offenlegen.

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

if errors.Is(err, sql.ErrNoRows) {
	// Aufrufer weiß jetzt, dass Sie database/sql verwenden
}

Wenn Sie das Speichermedium später ändern können, bevorzugen Sie einen Domänen-Sentinel:

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

Geben Sie dann diesen an der Paketgrenze zurück.

Fehlergrenzen (Error Boundaries)

Die nützlichste Art, über Go-Fehlerbehandlung nachzudenken, ist über Grenzen.

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

Häufige Grenzen umfassen:

  • Datenbank zu Repository
  • Repository zu Service
  • Service zu HTTP-Handler
  • Service zu CLI-Befehl
  • interner Fehler zu nutzerorientierter Nachricht
  • transiente Fehler zu Retry-Entscheidung
  • Operation-Fehler zu Log-Event
  • Domänenfehler zu API-Antwort

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

Repository-Grenze

Das Repository spricht mit dem Speicher.

Es sollte normalerweise datenbankspezifische 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 darüber wissen muss, wie der Speicher „nicht gefunden“ darstellt.

Service-Grenze

Der Service besitzt die geschäftliche Bedeutung.

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
}

Das bewahrt die Domänenbedingung und fügt Kontext für unerwartete Fehler hinzu.

Für komplexere Geschäftsregeln kann der Service direkt Domänenfehler 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, nicht aus Infrastrukturbedingungen übersetzt.

HTTP-Handler-Grenze

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

Das ist eine Grenze, an der interne Details zu nutzerfreundlichen Antworten werden sollten.

Web-Frameworks unterscheiden sich darin, wie sie diese Übersetzung strukturieren. Echo leitet Handler-Fehler über einen einzigen HTTPErrorHandler weiter, während Gin-Handler Fehler direkt im Request-Kontext rendert – die Abwägungen zwischen den beiden Stilen werden im Vergleich in Go-Web-Frameworks 2026: net/http, chi, Gin, Echo, Fiber im Vergleich.

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-Semantik, 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 Bauen von REST-APIs in Go Authentifizierung, Routing und Fehlerbehandlung ü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 nützlich sein, die den Befehl ausführt.

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, sollte aber trotzdem vermeiden, Secrets preiszugeben.

API-Fehlertyp-Muster

Für HTTP-APIs kann ein kleiner anwendungsnaher 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. Zwingen Sie nicht jedes interne Paket dazu, 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 Serviceschicht 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 Anleitung, wo Sie Domänenfehler, Sentinels und Transport-Adapter innerhalb Ihrer Projektstruktur definieren, deckt Go-Projektstruktur: Praktiken & Muster die internal/, pkg/ und Adapter-Konventionen ab, die diese Schichten sauber getrennt halten.

Retry-Fähige Fehler

Einige Fehler sollten eine Wiederholung auslösen. Andere nicht.

Entscheiden Sie dies nicht durch String-Abgleichen.

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
}

Helper:

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) {
		// mit Backoff wiederholen
	}
	return err
}

Das ist viel besser als zu prüfen, ob der Fehlerstring „timeout“ enthält – String-Abgleichen bricht stillschweigend, 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, weil der Aufrufer strukturierte Informationen benötigt – Feldnamen und Validierungsnachrichten – und nicht nur einen undurchsichtigen Fehlerstring.

Mehrere Fehler

Manchmal schlagen mehrere Dinge fehl.

Beispiele:

  • Schließen mehrerer Ressourcen
  • Validieren 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 verbundene Fehler untersuchen, was bedeutet, dass verbundene Fehlerwerte vollständig mit Standard-Fehlerprüfmustern kompatibel 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 verbundenen Fehler an Benutzer zurück.

Verbundene Fehler sind nützlich, können aber schnell unklar werden.

Panic ist keine Fehlerbehandlung

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 Verletzung einer internen Invariante
  • ungültige Paket-Initialisierung
  • Test-Helper-Fehler mit t.Fatal oder Panic in begrenzten Fällen
  • nicht wiederherstellbarer Startkonfigurationsfehler, abhängig vom Stil

Panic nicht, weil eine Datenbankabfrage fehlgeschlagen ist oder ein Benutzer ungültige Eingaben gemacht hat.

Das sind normale Fehler.

Fehler loggen

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

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
}

Das erstellt doppelte Logs für einen Fehlschlag.

Besser:

  • Fehler einbetten, während sie nach oben wandern
  • einmal an der Grenze loggen, 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,
	)
}

Das gibt ein Log-Event mit der vollständigen Fehlerkette. Für ein produktionsreifes strukturiertes Logging-Setup deckt Strukturiertes Logging in Go mit slog log/slog-Records, JSON-Handler, Kontextkorrelation und Redaction ab – all das passt natürlich zu grenznaher Fehlerlog-Verarbeitung.

Wann in unteren Schichten loggen

Loggen Sie in unteren Schichten nur, wenn die Schicht den Fehler tatsächlich behandelt oder wichtigen Betrieb kontext fügt hinzu, der andernorts nicht sichtbar wäre.

Zum Beispiel kann eine Retry-Schleife jeden Versuch auf Debug- oder Warnstufe loggen.

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

Nutzerorientierte Fehler vs. Betreiber-Fehler

Zeigen Sie interne Fehler nicht direkt an Benutzer.

Interner Fehler:

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

Nutzerorientierte Nachricht:

internal server error

Betreiber-Log:

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

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

  • interner diagnostischer Fehler
  • nutzerfreundliche Antwort
  • stabiler API-Fehlercode
  • Betreiber-Log-Kontext

Ein Fehlerstring für all diese Zielgruppen zu erzwingen, erzeugt entweder ein Sicherheitsrisiko oder einen Debug-Nachttm. Gestalten Sie Ihre Fehlerarchitektur um unterschiedliche Werte für unterschiedliche Konsumenten.

Sichere Fehlerbehandlung

Fehler können sensible Informationen preisleben.

Vermeiden Sie die Preisgabe:

  • Datenbank-Verbindungsstrings
  • SQL-Abfragen mit Secrets
  • interne Hostnamen
  • Dateipfade
  • Access-Tokens
  • API-Keys
  • Stack-Traces
  • private Kundendaten
  • Detailinformationen zu Autorisierungsrichtlinien

Das ist besonders in HTTP-APIs wichtig.

Schlecht:

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

Gut:

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

Loggen Sie den internen Fehler sicher für Betreiber. 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-Diagnose

Lassen Sie Clients keine englischen Fehlernachrichten parsen.

Ein praktisches schichtbasierendes Fehlerdesign

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

Repository-Schicht

  • Spricht mit Datenbank oder externem Speicher.
  • Wandelt speicher-spezifische Nicht-Gefunden-Fehler in Domänenfehler um.
  • Betettet unerwartete Speicherfehler mit Operationskontext.
  • Gibt keine HTTP-Fehler zurück.
  • Loggt 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.
  • Betettet unerwartete unterlevelige Fehler.
  • Gibt keine HTTP-Statuscodes zurück.
  • Loggt normalerweise nicht.

Beispiel:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Transport-Schicht

  • Mappt Domänenfehler auf HTTP-, gRPC- oder CLI-Antworten.
  • Loggt 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 die Fehlerbehandlung verständlich und lässt jede Schicht unabhängig evolvieren – Sie können die Speichertechnologie ändern, ohne Service-Logik oder Transport-Mapping zu berühren. Das schichtbasierte Design funktioniert am besten, wenn Abhängigkeiten injiziert statt hartkodiert werden; Dependency Injection in Go: Muster & Best Practices deckt die Konstruktor- und Interface-Muster ab, die es leicht machen, jede Grenze isoliert zu testen.

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-Abgleichen
  • keine Transport-Preisgabe in den Domänencode

Das ist die Art von Fehlerarchitektur, die skalierbar ist – einfach genug für einen neuen Mitwirkenden, aber strukturiert genug, damit Geschäftslogik nie in Transport-Antworten ausläuft.

Testen des Fehlerverhaltens

Fehlerverhalten sollte genauso gründlich getestet werden wie der Happy Path, denn Grenzentscheidungen – Sentinel-Mapping, Typextraktion, HTTP-Codes – sind oft der Ort, an dem Bugs am längsten versteckt bleiben. Für einen vollständigen Leitfaden zur Go-Teststruktur, Mocking und Coverage-Mustern, siehe Go Unit Testing: Struktur & Best Practices.

Sentinel-Mapping testen

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)
	}
}

Benutzerdefinierte Fehlerextraktion testen

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")
	}
}

HTTP-Mapping testen

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 Refactoring von Speicher- oder Transportschichten den Fehlervertrag nicht stillschweigend ändern kann.

Häufige Anti-Patterns

Anti-Pattern 1: String-Abgleichen

Schlecht:

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

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

Anti-Pattern 2: Die Ursache verlieren

Schlecht:

return errors.New("query failed")

Besser:

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

Anti-Pattern 3: Einbetten ohne Bedeutung

Schlecht:

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

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

Anti-Pattern 4: Loggen in jeder Schicht

Schlecht:

log.Println(err)
return err

auf jeder Ebene. Loggen Sie einmal, wo der Fehler schließlich behandelt wird, nicht in jeder Zwischenschicht, die ihn einfach nach oben weiterleitet.

Anti-Pattern 5: HTTP-Fehler aus Domänencode zurückgeben

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, halten Sie Ihre Serviceschicht unabhängig von Transportbelangen.

Anti-Pattern 6: Interne Fehler an Benutzer preisgeben

Schlecht:

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

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

Anti-Pattern 7: Zu viele exportierte Sentinels

Exportierte Fehler sind Teil Ihrer Paket-API, und das Hinzufügen verpflichtet Sie, sie zu warten. Exportieren Sie nicht jede interne Bedingung, es sei denn, externe Aufrufer müssen tatsächlich darauf verzweigen – bevorzugen Sie es, Sentinels unexportiert zu lassen, bis ein klarer Bedarf besteht.

Anti-Pattern 8: Panic für erwartete Fehlschläge verwenden

Schlecht:

panic(err)

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

Anti-Pattern 9: Kontextfehler ignorieren

Schlecht:

return fmt.Errorf("request failed")

wenn die wahre Ursache context.Canceled war. Bewahren Sie Kontextfehler, damit Aufrufer zwischen einem echten Operationsfehler und einer abgebrochenen oder timeout-geplagten Anfrage unterscheiden können und angemessen auf jede reagieren. Für eine eingehende Behandlung davon, wie Kontext-Abbruch und Timeout-Propagation über Serviceschichten hinweg funktionieren, siehe Go context.Context Richtig gemacht.

Fehler-Review-Checkliste

Verwenden Sie diese Checkliste im Code-Review.

Fehlererstellung

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

Fehler-Einbettung

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

Fehler-Übersetzung

  • Wird ein tiefleveliger Fehler an der richtigen Grenze übersetzt?
  • Ist datenbank-spezifisches Verhalten vom Servicecode versteckt?
  • Sind Domänenfehler unabhängig von HTTP- oder CLI-Belangen?

Fehlerbehandlung

  • Verzweigt der Aufrufer mit errors.Is oder errors.As?
  • Werden Kontext-Abbruch und Deadlines korrekt behandelt?
  • Werden retry-fähige Fehler explizit identifiziert?
  • Sind Validierungsfehler strukturiert?

Logging

  • Wird der Fehler einmal, an der Behandlungsgrenze, geloggt?
  • Sind die Logs strukturiert?
  • Sind sensible Details aus Benutzerantworten ausgeschlossen?
  • Gibt es genug Kontext für Betreiber?

Testen

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

Meine Meinungsvolle Regeln

Regel 1: Fehler sollten Grenzen mit Bedeutung überschreiten

Geben Sie Fehler nicht einfach weiter. Entscheiden Sie, was sie in jeder Schicht bedeuten.

Regel 2: Für Kontext einbetten, nicht für Dekoration

Wenn Wrapping keine nützliche Information über die fehlgeschlagene Operation hinzufügt, betten Sie nicht ein. Eine zusätzliche Kontextebene 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 Speichergrenze, damit der Rest der Anwendung nie wissen muss, welche Datenbank oder ORM darunter liegt.

Regel 4: Keine Fehlerstrings parsen

Wenn Code auf Fehlertyp verzweigen muss, verwenden Sie Sentinels, benutzerdefinierte Typen, errors.Is oder errors.As. String-Inspektion erzeugt unsichtbare Kopplung, die stillschweigend bricht, wenn sich Fehlermeldungen ändern.

Regel 5: Einmal loggen

Betten Sie ein, während Fehler nach oben wandern. Loggen Sie dort, wo der Fehler schließlich behandelt wird.

Regel 6: Benutzernachrichten sicher halten

Interne diagnostische Fehler sind für Logs. Nutzerorientierte 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-Services. Domänencode sollte über Transport wiederverwendbar sein – heute HTTP, morgen CLI, gRPC oder ein ereignisgesteuerter Worker.

Schlussgedanken

Go-Fehlerbehandlung geht nicht darum, für immer if err != nil zu schreiben – es geht darum, Fehler an jeder Grenze explizit und verständlich zu machen.

Die Mechanik ist einfach:

Fehler zurückgeben
mit %w einbetten
mit errors.Is prüfen
mit errors.As extrahieren
kombinieren, wenn mehrere Fehler wichtig sind

Die Architektur ist der schwierigere Teil:

an Grenzen übersetzen
Ursachen bewahren
Interne vor Benutzern verbergen
einmal loggen
bekannte Fehlschläge testen

Das ist gute Go-Fehlerbehandlung – nicht clever, nicht magisch, aber klar genug, damit der nächste Entwickler, Betreiber, API-Client und Ihr zukünftiges 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, Testen und Datenzugriff hinweg, siehe App-Architektur in Produktion.

Quellen

Abonnieren

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