Architektur zur Fehlerbehandlung in Go: Grenzen und Muster

Behandeln Sie Fehler an der richtigen Grenze.

Inhaltsverzeichnis

Das Fehlerhandling in Go ist ein beliebter Kritikpunkt. 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 angezeigt werden soll – das ist die architektonische Frage.

Go behandelt Fehler als Werte. Das macht Fehler explizit. Es bedeutet auch, dass Ihre Codebasis ein klares Design für das Fehlerhandling benötigt. Ohne eines werden Fehler zu willkürlichen Zeichenketten, HTTP-Handler geben Datenbankdetails preis, Protokolle doppelten denselben Fehlschlag fünfmal, Wiederholungsversuche (Retries) geschehen aus den falschen Gründen und Aufrufer analysieren Text statt des Verhaltens.

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 des Go-Fehlerhandlings: Einwickeln (Wrapping), Sentinel-Fehler, benutzerdefinierte Fehlertypen, errors.Is, errors.As, Fehlergrenzen (Error Boundaries), API-Zuordnung, Protokollierung, Wiederholungsversuche, Sicherheit und Muster für den Produktiveinsatz.

Die leicht gegebene Version: Versuchen Sie nicht, Go-Fehler verschwinden zu lassen. Machen Sie sie an der richtigen Grenze (Boundary) 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 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 Ausnahmen und kein verstecktes Stack-Unwinding. Fehler sind Teil der Funktionssignatur.

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 Administratoren laute Nachrichten und Entwickler verwirrende Ketten. Wenn keine Schicht Fehler einwickelt, verlieren Fehler ihren Kontext.

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

Dies gibt Kontext. Es sagt, dass der Fehlschlag 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 (Boundary) 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 benutzersichere Nachricht anzeigen
  • Die Transaktion abbrechen
  • Den Fehler an die Überwachung senden
  • Abbruch ignorieren

Diese Entscheidung sollte normalerweise auf Fehleridentität oder -typ basieren, nicht auf Zeichenkettenabgleich.

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 überprüfen, ob ein Fehler an einer bestimmten Stelle in seiner Kette einem bestimmten Ziel entspricht:

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 mit Vorsicht. Ein verbundener Fehler bedeutet, dass mehrere Fehlschläge 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 Fehlschlag 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 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 Fehlschlag Sentinels.

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 Sentinels. 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 bedeutungsvolle 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 aus.

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 Operation auf hoher Ebene: Konfiguration lesen
  • die Ursache auf niedriger Ebene: Berechtigung verweigert, Datei nicht gefunden usw.

Beides ist durch die Fehlerkette verfügbar, was das konsequente Einwickeln mit %w sinnvoll macht.

Einwickeln mit nützlichem Kontext

Gutes Einwickeln sagt, welche Operation fehlgeschlagen ist:

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

Schlechtes Einwickeln fügt Rauschen hinzu:

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

Dies 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 nervig.

Wickeln Sie ein, wo der Kontext seine 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 Überqueren einer bedeutungsvollen Grenze

Wickeln Sie ein, wenn der Fehler von einer Operation zu einer höherwertigen 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 hinzugefügte Kontext ist nützlich, wenn Administratoren einen Fehlschlag über die Protokolle zurückverfolgen.

Nicht nur einwickeln, um „fehlgeschlagen“ zu sagen

Schlecht:

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

Das Wort „fehlgeschlagen“ ist normalerweise impliziert durch die Tatsache, dass ein Fehler existiert.

Nicht einwickeln, wenn Sie übersetzen

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

Beispiel:

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

Dies verbirgt absichtlich das Datenbankdetail und zeigt eine Domänenbedingung an.

Sie können die Ursache möglicherweise trotzdem erhalten, wenn es nützlich ist, aber tun Sie dies bewusst.

Implementierungsdetails nicht versehentlich preisgeben

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 Einwickeln Implementierungsdetails als Teil Ihres Vertrags offenlegen.

Wenn Ihr Paket beispielsweise sql.ErrNoRows einwickelt, können Aufrufer anfangen, 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")

Geben Sie diesen dann an der Paketgrenze zurück.

Fehlergrenzen (Error Boundaries)

Die nützlichste Art, über Go-Fehlerhandling 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 Fehlschlag zu Wiederholungsentscheidung
  • Operationsfehlschlag zu Protokollereignis
  • 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 spezifische Datenbankfehler 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 Operationkontext 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.

Bei komplexeren 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äftliche Fehler – direkt aus der Domänenlogik erstellt, statt aus Infrastrukturbedingungen übersetzt.

HTTP-Handler-Grenze

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

Dies ist eine Grenze, an der interne Details benutzersichere 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)
	}
}

Fehlerzuordnung:

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 ordnet Domänenfehler HTTP-Semantik zu, anstatt rohe Datenbank- oder interne Fehlerdetails preiszugeben. Hier machen viele Go-Anwendungen Fehler – sie geben entweder zu viele interne Details preis oder kollabieren alle Fehler zu HTTP 500. Für ein vollständiges Bild von Handler-Mustern und Middleware in Go-APIs, Building REST APIs in Go deckt Authentifizierung, Routing und Fehlerhandling über die Standardbibliothek, 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 Befehls-Grenze:

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

Ordnen Sie bekannte Fehler Exit-Codes zu:

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 preiszugeben.

API-Fehlertyp-Muster

Für HTTP-APIs kann ein kleiner anwendungsspezifischer 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, 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")

Transportzuordnung:

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. Transportzuordnung gehört an die Transportgrenze, nicht in Domänencode. Für Leitlinien zur Definition von Domänenfehlern, Sentinels und Transport-Adaptern innerhalb Ihres Projektlayouts, Go Project Structure: Practices & Patterns deckt die internal/, pkg/- und Adapter-Konventionen ab, die diese Schichten sauber getrennt halten.

Retryable Errors (Wiederholbare Fehler)

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

Entscheiden Sie dies nicht durch Zeichenkettenabgleich.

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
}

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

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

Wiederholungs-Schleife:

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

Dies ist viel besser als das Überprüfen, ob die Fehlerzeichenkette „timeout“ enthält – Zeichenkettenabgleich bricht stillschweigend ab, wenn sich Nachrichten ändern, und erzeugt 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 eine undurchsichtige Fehlerzeichenkette.

Multiple Fehler

Manchmal schlagen mehrere Dinge fehl.

Beispiele:

  • mehrere Ressourcen schließen
  • viele Felder validieren
  • mehrere Worker herunterfahren
  • unabhängige Prüfungen durchführen
  • Ausgabe flushen und schließen

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 vermeiden, zu entscheiden, welcher Fehler wichtig ist.

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

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

Panic ist kein Fehlerhandling

Verwenden Sie in normalem Anwendungscode kein Panic für erwartete Fehler.

Schlecht:

if err != nil {
	panic(err)
}

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

Beispiele:

  • unmöglicher Verstoß gegen interne Invarianten
  • ungültige Paketinitialisierung
  • Testhelper-Fehlschlag mit t.Fatal oder Panic in begrenzten Fällen
  • nicht wiederherstellbarer Startkonfigurationsfehler, abhängig vom Stil

Verwenden Sie kein Panic, weil eine Datenbankabfrage fehlgeschlagen ist oder ein Benutzer ungültige Eingaben übermittelt hat.

Das sind normale Fehler.

Fehler protokollieren

Ein häufiger Go-Fehler ist, 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 Protokolle für einen Fehlschlag.

Besser:

  • Fehler einwickeln, während sie nach oben wandern
  • einmal an der Grenze protokollieren, an der der Fehler behandelt wird
  • strukturierten Kontext im Protokoll 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 Protokollereignis mit der vollständigen Fehlerkette. Für eine produktionsreife strukturierte Protokollierungseinrichtung, Structured Logging in Go with slog deckt log/slog-Aufzeichnungen, JSON-Handler, Kontextkorrelation und Redaktion ab – all das passt natürlich zur fehlerbezogenen Grenzprotokollierung.

Wann man in unteren Schichten protokolliert

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

Beispielsweise kann eine Wiederholungsschleife jeden Wiederholungsversuch auf Debug- oder Warnstufe protokollieren.

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

Benutzerfehler vs. Operatorfehler

Zeigen Sie interne Fehler nicht direkt Benutzern an.

Interner Fehler:

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

Benutzerausgerichtete Nachricht:

internal server error

Operator-Protokoll:

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
  • Operator-Protokollkontext

Das Erzwingen einer Fehlerzeichenkette, um all diese Zielgruppen zu bedienen, erzeugt entweder ein Expositionsrisiko oder ein Fehlerbehebungs-Nightmare. Gestalten Sie Ihre Fehlerarchitektur um distinkte Werte für distinkte Konsumenten.

Sicheres Fehlerhandling

Fehler können sensible Informationen preisgeben.

Vermeiden Sie die Offenlegung von:

  • Datenbankverbindungszeichenketten
  • SQL-Abfragen mit Geheimnissen
  • internen Hostnamen
  • Dateipfaden
  • Zugriffstokens
  • API-Schlüsseln
  • Stack-Traces
  • privaten Kundendaten
  • Autorisierungsrichtliniendetails

Dies gilt insbesondere für HTTP-APIs.

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 Administratoren. Geben Sie eine sichere Nachricht an den Benutzer zurück.

Fehlercodes

Für öffentliche APIs sind stabile Fehlercodes oft besser als das alleinige Vertrauen in Nachrichten.

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-Backend-Services.

Repository-Schicht

  • Spricht mit Datenbank oder externem Speicher.
  • Konvertiert spezifische Speicher-Nicht-Gefunden-Fehler in Domänenfehler.
  • Wickelt unerwartete Speicherfehler mit Operationkontext 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 Low-Level-Fehler ein.
  • Gibt keine HTTP-Statuscodes zurück.
  • Protokolliert normalerweise nicht.

Beispiel:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Transport-Schicht

  • Ordnet Domänenfehler HTTP-, gRPC- oder CLI-Antworten zu.
  • Protokolliert unbehandelte oder unerwartete Fehler.
  • Verbirgt interne Details vor Benutzern.
  • Legt Statuscodes und API-Fehlercodes fest.

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 entwickeln – Sie können die Speichertechnologie ändern, ohne die Servicelogik oder Transportzuordnung zu berühren. Das geschichtete Design funktioniert am besten, wenn Abhängigkeiten injiziert statt hartkodiert werden; Dependency Injection in Go: Patterns & Best Practices deckt die Konstruktor- und Interface-Muster ab, die jede Grenze einfach 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-Zuordnung
  • inspizierbare Fehlerketten
  • keinen Zeichenkettenabgleich
  • keine Transport-Leckage in Domänencode

Das ist die Art von Fehlerarchitektur, die skaliert – einfach genug für einen neuen Mitwirkenden zu verstehen, aber strukturiert genug, dass Domänenlogik nie in Transportantworten ausläuft.

Testen des Fehlerverhaltens

Fehlerverhalten sollte genauso gründlich getestet werden wie der glückliche Pfad, weil Grenzentscheidungen – Sentinel-Zuordnung, Typextraktion, HTTP-Codes – oft dort sind, wo Bugs am längsten versteckt bleiben. Für einen vollständigen Leitfaden zur Go-Teststruktur, Mocking und Abdeckungsmustern, siehe Go Unit Testing: Structure & Best Practices.

Sentinel-Zuordnung 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-Zuordnung 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 das Refactoring von Speicher- oder Transport-Schichten den Fehlschlagvertrag nicht stillschweigend ändern kann.

Häufige Anti-Patterns

Anti-Pattern 1: Zeichenkettenabgleich

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: Die Ursache verlieren

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 Operationkontext ein, der erklärt, was versucht wurde, wie "create invoice %s: %w", anstatt einem vagen Präfix, das keinen diagnostischen Wert hinzufügt.

Anti-Pattern 4: Protokollieren in jeder Schicht

Schlecht:

log.Println(err)
return err

in jedem Level. Protokollieren Sie einmal, wo der Fehler endlich 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änenservice. Ordnen Sie Domänenfehler HTTP-Statuscodes und Antwortkörpern an der Handler-Grenze zu, und halten Sie Ihre Serviceschicht unabhängig von Transportbelangen.

Anti-Pattern 6: Interne Fehler an Benutzer preiszugeben

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 Administratoren. Geben Sie niemals Datenbankverbindungszeichenketten, Dateipfade oder rohe Stack-Traces in API-Antworten frei.

Anti-Pattern 7: Zu viele exportierte Sentinels

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

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

Schlecht:

panic(err)

für normale Laufzeitfehlschläge. Behalten Sie Panic für wirklich nicht wiederherstellbare Bedingungen oder Programmierfehler bei, 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 auf, sodass Aufrufer zwischen einem echten Operationsfehlschlag und einer abgebrochenen oder zeitüberschrittenen Anfrage unterscheiden und auf jede angemessen reagieren können. Für eine gründliche Behandlung davon, wie Kontextabbruch und Timeout-Propagation über Serviceschichten hinweg funktionieren, siehe Go context.Context Done Right.

Fehler-Review-Checkliste

Verwenden Sie diese Checkliste im 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 einwickeln

  • Fügt das Einwickeln nützlichen Operationkontext hinzu?
  • Bewahrt %w die Ursache, wo nötig?
  • Entlarvt der Code versehentlich Implementierungsdetails?
  • Ist die Kette zu laut?

Fehlerübersetzung

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

Fehlerbehandlung

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

Protokollierung

  • Wird der Fehler einmal, an der Behandlungsgrenze, protokolliert?
  • Sind die Protokolle strukturiert?
  • Werden sensible Details aus Benutzerantworten ausgeschlossen?
  • Gibt es genug Kontext für Administratoren?

Testen

  • Werden bekannte Fehlerfälle getestet?
  • Werden HTTP- oder CLI-Zuordnungen getestet?
  • Werden Validierungsdetails getestet?
  • Werden Wiederholungsentscheidungen getestet?

Meine gegebene Regeln

Regel 1: Fehler sollten Grenzen mit Bedeutung überschreiten

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

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

Wenn Einwickeln keine nützlichen Informationen darüber hinzufügt, welche Operation fehlgeschlagen ist, wickeln Sie nicht ein. Eine zusätzliche Kontextschicht 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 Speicher-Grenze in Domänenfehler, sodass der Rest der Anwendung nie wissen muss, welche Datenbank oder ORM darunterliegt.

Regel 4: Keine Fehlerzeichenketten parsen

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

Regel 5: Einmal protokollieren

Wickeln Sie ein, während Fehler nach oben wandern. Protokollieren Sie, wo der Fehler endlich behandelt wird.

Regel 6: Benutzernachrichten sicher halten

Interne diagnostische Fehler sind für Protokolle. Benutzerausgerichtete Nachrichten sind für Benutzer.

Regel 7: Transportfehler an der Transportgrenze halten

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

Abschließende Gedanken

Go-Fehlerhandling geht nicht darum, if err != nil für immer zu schreiben – es geht darum, Fehlschlag 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-Fehlerhandling gut gemacht – nicht clever, nicht magisch, aber klar genug, dass der nächste Entwickler, Administrator, API-Client und zukünftige Sie verstehen können, was fehlgeschlagen ist und was als nächstes passieren sollte. Für einen breiteren Blick auf Produktions-Go-Muster über Integration, Testen und Datenzugriff, siehe App Architecture in Production.

Quellen

Abonnieren

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