Go-Fehlerbehandlungsarchitektur: Grenzen und Muster

Behandeln Sie Fehler an der richtigen Grenze.

Inhaltsverzeichnis

Go-Fehlerbehandlung ist leicht zu kritisieren. 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 eingewickelt (wrapped) werden sollte, wo er übersetzt werden sollte, wo er protokolliert werden sollte und was dem Aufrufer offengelegt werden soll – das ist die architektonische Frage.

Go behandelt Fehler als Werte. Das macht Ausfälle explizit. Es bedeutet auch, dass Ihre Codebasis ein klares Design für die Fehlerbehandlung benötigt. Ohne eines werden Fehler zu willkürlichen Zeichenketten, HTTP-Handler geben Datenbankdetails weiter, Logs doppelten denselben Fehler fünfmal, Wiederholungsversuche (Retries) erfolgen aus den falschen Gründen und Aufrufer prüfen Text statt Verhalten.

Go-Fehlerbehandlungsarchitektur: Fehler fließen zwischen Schichten

Dieser Artikel ist keine Einführung für Anfänger zu if err != nil.

Es ist ein praktischer Leitfaden zur Architektur der Go-Fehlerbehandlung: Einwickeln (Wrapping), Sentinel-Fehler, benutzerdefinierte Fehlertypen, errors.Is, errors.As, Fehlergrenzen (Boundaries), API-Mapping, Protokollierung, Wiederholungsversuche, Sicherheit und Produktionsmuster.

Die etwas opinionated Version: Versuchen Sie nicht, Go-Fehler verschwinden zu lassen. Machen Sie sie an der richtigen Grenze (Boundary) bedeutsam.

Was Go-Fehler sind

In Go ist ein Fehler einfach ein Wert, der diese Schnittstelle implementiert:

type error interface {
	Error() string
}

Diese kleine Schnittstelle ist der Grund, warum die Go-Fehlerbehandlung 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 Ausnahmen und kein verstecktes Stack-Unwinding. Fehler ist Teil der Funktions signatur.

Das ist gut, bedeutet aber auch, dass Fehler ein Design benötigen. Wenn jedes Paket willkürliche Nachrichten zurückgibt, können Aufrufer keine zuverlässigen Entscheidungen treffen. Wenn jede Schicht jeden Fehler ohne Disziplin einwickelt, erhalten Betreiber laute Nachrichten und Entwickler verwirrte Ketten. Wenn keine Schicht Fehler einwickelt, verlieren Fehler ihren Kontext.

Das Ziel ist nicht weniger Fehlerbehandlung, sondern 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)

Dies gibt Kontext. Es sagt, dass der Fehler beim Laden eines Benutzers 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)

Das %w wickelt den ursprünglichen Fehler ein, sodass Aufrufer ihn mit errors.Is oder errors.As inspizieren können.

Aufgabe 3: Einer Grenze eine Entscheidung ermöglichen

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

Beispiele:

  • HTTP 404 zurückgeben
  • HTTP 409 zurückgeben
  • Die Operation wiederholen
  • Auf Warnstufe protokollieren
  • Eine benutzerfreundliche Nachricht anzeigen
  • Die Transaktion abbrechen
  • Den Fehler an die Überwachung senden
  • Abbruch ignorieren

Diese Entscheidung sollte normalerweise auf der Fehleridentität oder dem Typ basieren, nicht auf String-Vergleichen.

Die wichtigsten Fehlerwerkzeuge im modernen Go

Modernes Go bietet Ihnen einen kleinen, aber leistungsstarken Satz von Werkzeugen.

errors.New

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

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

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

fmt.Errorf mit %w

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

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

Einpacken fügt Kontext hinzu, während der ursprüngliche Fehler zur Inspektion erhalten bleibt.

errors.Is

Verwenden Sie errors.Is, um zu prüfen, ob ein Fehler an einer bestimmten Stelle in seiner Kette mit einem bestimmten Ziel übereinstimmt:

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 aufgetreten sind und alle erhalten bleiben sollen:

return errors.Join(closeErr, flushErr)

Verbundene Fehler können weiterhin mit errors.Is und errors.As inspiziert werden.

Verwenden Sie dies vorsichtig. Ein verbundener Fehler bedeutet, dass mehrere Ausfälle 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 des Fehlers 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 Dienst oder Handler prüfen:

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

Wann man Sentinel-Fehler verwendet

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 man Sentinel-Fehler nicht verwendet

Erstellen Sie nicht für jeden möglichen Fehler Sentinel-Fehler.

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

Seien Sie auch vorsichtig beim 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)
}

Dies ist besser als das Parsen einer Fehlerzeichenkette.

Wann man benutzerdefinierte Fehlertypen verwendet

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 Feldname
  • Rate-Limit-Fehler mit Wiederholungszeit
  • HTTP-Fehler mit Statuscode
  • Parse-Fehler mit Zeile und Spalte
  • Domänenfehler mit Ressourcen-ID

Wann man benutzerdefinierte Fehlertypen nicht verwendet

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 keine nützlichen Daten vorhanden sind, reicht oft ein Sentinel.

Fehler einwickeln (Wrapping)

Einwickeln fügt einem Fehler Kontext hinzu, während der ursprüngliche Fehler erhalten bleibt.

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 hochrangige Operation: Konfiguration lesen
  • die niedrige Ursache: Berechtigung verweigert, Datei nicht gefunden usw.

Beide sind durch die Fehlerkette verfügbar, was das konsequente Einpacken mit %w sinnvoll macht.

Mit nützlichem Kontext einwickeln

Gutes Einpacken sagt, welche Operation fehlgeschlagen ist:

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

Schlechtes Einpacken fügt Rauschen hinzu:

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

Das sagt dem Aufrufer nichts.

Vermeiden Sie auch die Wiederholung desselben Substantivs in jeder Schicht:

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 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 ein oder nicht genug.

Wann einwickeln und wann nicht

Dies ist eine der wichtigsten architektonischen Entscheidungen.

Einwickeln beim Überschreiten einer bedeutungsvollen Grenze

Wickeln Sie ein, wenn der Fehler von einer Operation zu einer höherwertigen Operation bewegt wird.

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 hinzugefügte Kontext ist nützlich, wenn Betreiber einen Fehler durch die Logs zurückverfolgen.

Nicht nur einwickeln, um “fehlgeschlagen” zu sagen

Schlecht:

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

Das Wort “fehlgeschlagen” wird normalerweise durch die Tatsache impliziert, dass ein Fehler existiert.

Nicht einwickeln, wenn Sie übersetzen

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

Beispiel:

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

Dies verbirgt absichtlich die Datenbankdetails und legt eine Domänenbedingung offen.

Sie können die Ursache weiterhin bewahren, wenn nützlich, aber tun Sie dies bewusst.

Implementierungsdetails nicht versehentlich offenlegen

Wenn Sie einen Low-Level-Fehler mit %w einwickeln, können Aufrufer ihn inspizieren.

Das ist normalerweise innerhalb Ihrer Anwendung gut.

Aber in einer öffentlichen Paket-API kann Einpacken Implementierungsdetails als Teil Ihres Vertrags offenlegen.

Zum Beispiel, wenn Ihr Paket sql.ErrNoRows einwickelt, können Aufrufer beginnen, darauf zu vertrauen:

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

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

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

Und geben Sie diesen an der Paketgrenze zurück.

Fehlergrenzen (Boundaries)

Der nützlichste Weg, über Go-Fehlerbehandlung nachzudenken, ist durch Grenzen.

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

Häufige Grenzen umfassen:

  • Datenbank zu Repository
  • Repository zu Service
  • Service zu HTTP-Handler
  • Service zu CLI-Befehl
  • Interner Fehler zu benutzerausgerichteter Nachricht
  • Vorübergehender Fehler zu Wiederholungsentscheidung
  • Operationsfehler zu Log-Ereignis
  • 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, auf die die nächste Schicht reagieren 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 legt ErrUserNotFound offen – 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
}

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

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 Fehler auf Geschäftsebene – direkt aus der 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.

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

Fehlermapping:

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, anstatt rohe Datenbank- oder interne Fehlerdetails zu offenlegen. Hier gehen viele Go-Anwendungen falsch vor – sie offenlegen entweder zu viele interne Details oder kollabieren alle Fehler zu HTTP 500. Für ein vollständiges Bild von Handler-Mustern und Middleware in Go-APIs deckt REST-APIs in Go bauen Authentifizierung, Routing und Fehlerbehandlung über die Standardbibliothek, Gin, Echo und Fiber hinweg.

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

Mappe bekannte Fehler auf Exit-Codes:

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 immer noch vermeiden, Geheimnisse zu lecken.

API-Fehlertyp-Muster

Für HTTP-APIs kann ein kleiner app-level 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,
	}
}

Nutzung:

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, dass jedes interne Paket API-spezifische Fehler zurückgibt.

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 arbeitet. Transport-Mapping gehört an die Transportgrenze, nicht in Domänencode. Für Anleitung darüber, wo man Domänenfehler, Sentinel und Transport-Adapter innerhalb Ihrer Projektstruktur definiert, deckt Go-Projektstruktur: Praktiken & Muster die internal/, pkg/ und Adapter-Konventionen ab, die diese Schichten sauber getrennt halten.

Wiederholbare Fehler (Retryable Errors)

Einige Fehler sollten Wiederholungsversuche auslösen. Andere sollten es nicht.

Entscheiden Sie dies nicht durch String-Vergleiche.

Verwenden Sie eine Marker-Schnittstelle 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
}

Hilfsfunktion:

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

Nutzung:

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

Wiederholungsloop:

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

Dies ist viel besser, als zu prüfen, ob die Fehlerzeichenkette “timeout” enthält – String-Vergleiche brechen stillschweigend, wenn sich Nachrichten ändern, und schaffen 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"
}

Nutzung:

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 – und nicht nur eine undurchsichtige Fehlerzeichenkette.

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 erhalten bleiben 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 inspizieren, was bedeutet, dass verbundene Fehlerwerte vollständig kompatibel mit Standard-Fehlerprüfungsmustern bleiben.

Wann man errors.Join nicht verwendet

Verwenden Sie errors.Join nicht, wenn es einen primären Fehler und einige Protokollierungskontext gibt.

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

Geben Sie keine riesigen verbundenen Fehler an Benutzer zurück.

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

Panic ist keine Fehlerbehandlung

Verwenden Sie in normalem Anwendungscode nicht panic 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 interner Invarianten
  • Ungültige Paketinitialisierung
  • Testhelper-Fehler mit t.Fatal oder panic in begrenzten Fällen
  • Nicht wiederherstellbarer Startkonfigurationsfehler, abhängig vom Stil

Verwenden Sie nicht panic, weil eine Datenbankabfrage fehlgeschlagen ist oder ein Benutzer ungültige Eingabe übermittelt hat.

Das sind normale Fehler.

Fehler protokollieren

Ein häufiger Go-Fehler ist das Protokollieren desselben Fehlers in jeder Schicht.

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 erstellt doppelte Logs für einen Fehler.

Besser:

  • Fehler einwickeln, wenn sie nach oben wandern
  • Einmalig an der Grenze protokollieren, an der der Fehler behandelt wird
  • Strukturierten Kontext im Log enthalten

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-Ereignis mit der vollständigen Fehlerkette. Für ein produktionsreifes Setup für strukturierte Protokollierung deckt Strukturierte Protokollierung in Go mit slog) log/slog-Records, JSON-Handler, Kontextkorrelation und Redaktion ab – all das passt natürlich zur Fehlerprotokollierung auf Grenz Ebene.

Wann man in niedrigeren Schichten protokolliert

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

Zum Beispiel kann ein Wiederholungsloop jeden Wiederholungsversuch auf Debug- oder Warnstufe protokollieren.

Aber ein Repository sollte nicht jede Abfragefehler protokollieren, wenn der Handler den endgültigen Anfragefehler protokollieren wird.

Benutzergerichtete Fehler vs. Betreiberfehler

Zeigen Sie keine internen Fehler direkt an Benutzer an.

Interner Fehler:

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

Benutzerausgerichtete 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 Diagnosefehler
  • Benutzersichere Antwort
  • Stabiler API-Fehlercode
  • Betreiber-Log-Kontext

Das Erzwingen einer Fehlerzeichenkette, um all diese Zielgruppen zu bedienen, produziert entweder ein Expositionsrisiko oder ein Debug-Alptraum. Entwerfen Sie Ihre Fehlerarchitektur um distinkte Werte für distinkte Konsumenten.

Sichere Fehlerbehandlung

Fehler können sensible Informationen lecken.

Vermeiden Sie das Offenlegen von:

  • Datenbankverbindungsstrings
  • SQL-Abfragen mit Geheimnissen
  • Internen Hostnamen
  • Dateipfaden
  • Zugriffstokens
  • API-Schlüsseln
  • 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 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.

Beispielantwort:

{
  "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-Diagnostik

Lassen Sie Clients keine englischen Fehlermeldungen parsen.

Ein praktisches geschichtetes Fehlerdesign

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

Repository-Schicht

  • Spricht mit Datenbank oder externem Speicher.
  • Konvertiert spezifische Speicher-“nicht gefunden”-Fehler in Domänenfehler.
  • 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 niedrigerwertige Fehler 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 unbehandelte 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 entwickeln – Sie können die Speichertechnologie ändern, ohne die Service-Logik oder Transport-Mapping zu berühren. Das geschichtete Design funktioniert am besten, wenn Abhängigkeiten injiziert werden, anstatt hard-codiert zu sein; Dependency Injection in Go: Muster & Best Practices) deckt die Constructor- und Interface-Muster ab, die jede Grenze leicht isoliert 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
  • Inspizierbare Fehlerketten
  • Keine String-Vergleiche
  • Keine Transport-Lecks in Domänencode

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

Testen von Fehlerverhalten

Fehlerverhalten sollte genauso gründlich getestet werden wie der glückliche Pfad, da Grenzentscheidungen – Sentinel-Mapping, Typextraktion, HTTP-Codes – oft der Ort sind, 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 produzieren, sodass Refactoring von Speicher- oder Transport-Schichten den Fehlervertrag nicht stillschweigend ändern können.

Häufige Anti-Patterns

Anti-Pattern 1: String-Vergleiche

Schlecht:

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

Verwenden Sie stattdessen errors.Is oder errors.As – beide behandeln eingewickelte 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: Einwickeln 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", anstatt einer vagen Prefix, die keinen diagnostischen Wert hinzufügt.

Anti-Pattern 4: Protokollieren in jeder Schicht

Schlecht:

log.Println(err)
return err

in jedem Level. Protokollieren Sie einmal dort, wo der Fehler schließlich behandelt wird, nicht in jeder Zwischenschicht, die ihn einfach nach oben weiterleitet.

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

Schlecht:

return &APIError{Status: http.StatusNotFound}

aus einem Domäenservice. Mappe Domänenfehler auf HTTP-Statuscodes und Antwortkörper an der Handler-Grenze, um deine Serviceschicht unabhängig von Transport-Anliegen zu halten.

Anti-Pattern 6: Offenlegen 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 Betreiber. Offenlegen Sie niemals Datenbankverbindungsstrings, Dateipfade oder rohe Stack-Traces in API-Antworten.

Anti-Pattern 7: Zu viele exportierte Sentinel-Fehler

Exportierte Fehler sind Teil Ihrer Paket-API, und das Hinzufügen von ihnen verpflichtet Sie zur Wartung. Exportieren Sie nicht jede interne Bedingung, es sei denn, externe Aufrufer müssen wirklich darauf verzweigen – bevorzugen Sie das Behalten 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. 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: Ignorieren von Kontextfehlern

Schlecht:

return fmt.Errorf("request failed")

wenn die wahre Ursache context.Canceled war. Bewahren Sie Kontextfehler auf, sodass Aufrufer zwischen einem echten Operationsfehler und einer abgebrochenen oder abgelaufenen Anfrage unterscheiden können und angemessen auf jede reagieren.

Fehlerüberprüfung-Checkliste

Verwenden Sie diese Checkliste in der Code-Review.

Fehlererstellung

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

Fehler-Einpacken

  • Fügt das Einpacken nützlichen Operationskontext hinzu?
  • Bewahrt %w die Ursache dort, wo nötig?
  • Legt der Code versehentlich Implementierungsdetails offen?
  • Ist die Kette zu laut?

Fehlerübersetzung

  • Wird ein Low-Level-Fehler an der richtigen Grenze übersetzt?
  • Ist datenbankspezifisches Verhalten vor Service-Code verborgen?
  • Sind Domänenfehler unabhängig von HTTP- oder CLI-Anliegen?

Fehlerbehandlung

  • Verzweigt der Aufrufer mit errors.Is oder errors.As?
  • Werden Kontextabbbruch und Timeouts korrekt behandelt?
  • Werden wiederholbare Fehler explizit identifiziert?
  • Sind Validierungsfehler strukturiert?

Protokollierung

  • Wird der Fehler einmalig an der Behandlungsgrenze protokolliert?
  • Sind Logs strukturiert?
  • Sind sensible Details aus Benutzerantworten ausgeschlossen?
  • Gibt es genug Kontext für Betreiber?

Testen

  • Sind bekannte Fehlerfälle getestet?
  • Sind HTTP- oder CLI-Mappings getestet?
  • Sind Validierungsdetails getestet?
  • Sind Wiederholungsentscheidungen getestet?

Meine opinionated Regeln

Regel 1: Fehler sollten Grenzen mit Bedeutung überschreiten

Reichen Sie Fehler nicht einfach herum. Entscheiden Sie, was sie in jeder Schicht bedeuten.

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

Wenn Einpacken keine nützlichen Informationen darüber hinzufügt, welche Operation fehlgeschlagen ist, wickeln Sie nicht ein. Eine zusätzliche Schicht von Kontext ohne Bedeutung macht die Fehlerkette schwerer zu lesen 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 an der Speichergrenze in Domänenfehler, sodass 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 Sentinel, benutzerdefinierte Typen, errors.Is oder errors.As. String-Inspektion schafft unsichtbare Kopplung, die stillschweigend bricht, wenn sich Fehlermeldungen ändern.

Regel 5: Einmalig protokollieren

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

Regel 6: Benutzernachrichten sicher halten

Interne Diagnosefehler sind für Logs. Benutzerorientierte Nachrichten sind für Benutzer.

Regel 7: Transportfehler an der Transportgrenze halten

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

Abschließende Gedanken

Go-Fehlerbehandlung geht nicht darum, if err != nil für immer 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 Go-Fehlerbehandlung gut gemacht – nicht clever, nicht magisch, aber klar genug, dass der nächste Entwickler, Betreiber, API-Client und zukünftige Sie verstehen können, was fehlgeschlagen ist und was als nächstes passieren sollte. Für einen breiteren Überblick über Produktions-Go-Muster über Integration, Testing und Datenzugriff hinweg, siehe App-Architektur in der Produktion.

Quellen

Abonnieren

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