Go-errorafhandeling: grenzen en patronen

Verwerk fouten op de juiste grens.

Inhoud

Het omgaan met fouten in Go is makkelijk om te klagen over. Elke Go-ontwikkelaar heeft honderden keren dit soort code geschreven:

if err != nil {
	return err
}

Dat is niet het interessante deel. Het interessante deel is wat de fout betekent, waar hij afgehandeld moet worden, waar hij verpakt (wrapped) moet worden, waar hij vertaald moet worden, waar hij gelogd moet worden en wat er naar de aanroeper moet worden toegetoond — dat is de architectuurvraag.

Go behandelt fouten als waarden. Dat maakt falen expliciet. Het betekent ook dat je codebase een duidelijk ontwerp voor foutafhandeling nodig heeft. Zonder zo’n ontwerp worden fouten willekeurige strings, lekken HTTP-handlers database-details, dupliceren logs hetzelfde probleem vijf keer, vinden herpogingen plaats om de verkeerde redenen en inspecteren aanroepers tekst in plaats van gedrag.

Go error handling architecture: errors flowing between layers

Dit artikel is geen beginner-inleiding tot if err != nil.

Het is een praktische gids voor Go-foutafhandelingarchitectuur: wrapping, sentinels, aangepaste fouttypes, errors.Is, errors.As, foutgrenzen, API-mapping, logging, herpogingen, beveiliging en productiemodellen.

De iets eigenzinnige versie: probeer niet om Go-fouten uit de weg te laten verdwijnen. Maak ze betekenisvol op de juiste grens.

Wat Go-fouten zijn

In Go is een fout simpelweg een waarde die deze interface implementeert:

type error interface {
	Error() string
}

Die kleine interface is de reden waarom Go-foutafhandeling zo direct aanvoelt.

Functies geven fouten expliciet terug:

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

Aanroepers beslissen wat ze moeten doen:

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

Er zijn geen uitzonderingen en geen verborgen stack-unwinding. Falen is onderdeel van de functiesignatuur.

Dat is goed, maar het betekent ook dat fouten ontwerp nodig hebben. Als elke package willekeurige berichten teruggeeft, kunnen aanroepers geen betrouwbare beslissingen nemen. Als elke laag elke fout verpakt zonder discipline, krijgen operators lawaaierige berichten en ontwikkelaars verwarde ketens. Als geen enkele laag fouten verpakt, verliezen fouten context.

Het doel is niet minder foutafhandeling, maar een betere betekenis van fouten.

De drie taken van een fout

Een nuttige fout heeft meestal één of meer taken.

Taak 1: Uitleggen wat misging

Voor mensen moet de fout uitleggen welke operatie mislukte.

Voorbeeld:

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

Dit geeft context. Het zegt dat het falen plaatsvond tijdens het laden van een gebruiker.

Taak 2: De oorzaak behouden

Voor code moet de fout de onderliggende oorzaak behouden wanneer die oorzaak belangrijk is.

Voorbeeld:

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

De %w verpakt de oorspronkelijke fout zodat aanroepers deze kunnen inspecteren met errors.Is of errors.As.

Taak 3: Een grens een besluit laten nemen

Op een bepaald moment moet het programma beslissen wat het moet doen.

Voorbeelden:

  • HTTP 404 teruggeven
  • HTTP 409 teruggeven
  • De operatie opnieuw proberen
  • Op waarschuwingsniveau loggen
  • Een gebruikersveilige bericht tonen
  • De transactie afbreken
  • De fout naar monitoring sturen
  • Annulering negeren

Dat besluit moet meestal gebaseerd zijn op foutidentiteit of type, niet op stringmatching.

De belangrijkste fouttools in modern Go

Modern Go biedt je een klein, maar krachtig set van tools.

errors.New

Gebruik errors.New om een eenvoudige foutwaarde te maken:

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

Dit is handig voor sentinel-fouten.

fmt.Errorf met %w

Gebruik fmt.Errorf met %w om een fout te verpakken:

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

Verpakken voegt context toe terwijl het de oorspronkelijke fout behoudt voor inspectie.

errors.Is

Gebruik errors.Is om te controleren of een fout ergens in zijn keten overeenkomt met een specifiek doelwit:

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

Gebruik dit voor sentinel-fouten en bekende omstandigheden.

errors.As

Gebruik errors.As om een specifiek fouttype uit een keten te extraheren:

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

Gebruik dit wanneer de fout gestructureerde gegevens bevat.

errors.Join

Gebruik errors.Join wanneer meerdere fouten zijn opgetreden en ze allemaal behouden moeten blijven:

return errors.Join(closeErr, flushErr)

Gecombineerde fouten kunnen nog steeds worden geïnspecteerd met errors.Is en errors.As.

Gebruik dit voorzichtig. Een gecombineerde fout betekent dat meerdere falen onderdeel zijn van één resultaat.

Sentinel-fouten

Een sentinel-fout is een foutwaarde op package-niveau die een bekende omstandigheid vertegenwoordigt.

Voorbeeld:

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

Sentinel-fouten zijn handig wanneer de aanroeper alleen weet hoeft te hebben welke categorie van falen heeft plaatsgevonden.

Voorbeeld:

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
}

Daarna kan een service of handler controleren:

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

Wanneer sentinel-fouten te gebruiken

Gebruik sentinel-fouten wanneer:

  • De omstandigheid stabiel is.
  • De aanroeper daarop moet takelen (branchen).
  • Geen extra gestructureerde gegevens nodig zijn.
  • De fout tot jouw package of domein behoort.

Goede voorbeelden:

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

Wanneer sentinel-fouten NIET te gebruiken

Maak geen sentinels voor elke mogelijke fout.

Slecht:

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

Als aanroepers hierop niet takelen, zijn ze misschien alleen maar berichten.

Wees ook voorzichtig met het exporteren van te veel sentinels. Geëxporteerde sentinel-fouten worden onderdeel van jouw package-API.

Aangepaste fouttypes

Een aangepast fouttype is handig wanneer de fout gestructureerde informatie bevat.

Voorbeeld:

type ValidationError struct {
	Field  string
	Reason string
}

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

Aanroeper:

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

Dit is beter dan het parseren van een foutstring.

Wanneer aangepaste fouttypes te gebruiken

Gebruik aangepaste fouttypes wanneer:

  • Aanroepers gestructureerde gegevens nodig hebben.
  • De fout betekenisvolle velden heeft.
  • Het type onderdeel is van jouw package-contract.
  • De aanroeper mogelijk meerdere waarden anders moet afhandelen.

Voorbeelden:

  • Validatiefout met veldnaam
  • Limietfout voor herhalingstijd
  • HTTP-fout met statuscode
  • Parsefout met regel en kolom
  • Domeinfout met resource-ID

Wanneer aangepaste fouttypes NIET te gebruiken

Maak geen aangepaste types alleen om errors.New te vermijden.

Dit is onnodig:

type NotFoundError struct{}

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

Als er geen nuttige data is, is een sentinel vaak voldoende.

Fouten verpakken (Wrapping)

Verpakken voegt context toe aan een fout terwijl het de oorspronkelijke fout behoudt.

Voorbeeld:

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
}

Als os.ReadFile faalt, krijgt de aanroeper beide:

  • de hoog-niveau operatie: config lezen
  • de laag-niveau oorzaak: toegang geweigerd, bestand niet gevonden, etc.

Beide zijn beschikbaar via de foutketen, wat het consistent verpakken met %w de moeite waard maakt.

Verpakken met nuttige context

Goed verpakken zegt welke operatie faalde:

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

Slecht verpakken voegt lawaai toe:

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

Dit vertelt de aanroeper niets.

Vermijd ook het herhalen van hetzelfde zelfstandige naamwoord op elke laag:

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

Die soort keten is technisch correct maar praktisch irritant.

Verpak waar de context van betekenis verandert. Als je niet in één zin kunt uitleggen welke operatie faalde, verpak je waarschijnlijk te agressief of niet genoeg.

Wanneer te verpakken en wanneer niet

Dit is een van de belangrijkste architectuurbeslissingen.

Verpakken bij het overschrijden van een betekenisvolle grens

Verpak wanneer de fout van één operatie naar een hogere-niveau operatie beweegt.

Voorbeeld:

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
}

De repository-fout is nu onderdeel van een service-operatie, en die toegevoegde context is nuttig wanneer operators een fout terug traceren door de logs.

Niet verpakken om alleen “gefaald” te zeggen

Slecht:

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

Het woord “gefaald” is meestal impliciet door het feit dat er een fout is.

Niet verpakken als je vertaalt

Soms moet je één fout vertalen naar een andere domeinfout.

Voorbeeld:

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

Dit verbergt bewust de database-detail en toont een domeinomstandigheid.

Je kunt de oorzaak nog steeds behouden als nuttig, maar doe het bewust.

Implementatiedetails niet per ongeluk blootstellen

Als je een laag-niveau fout verpakt met %w, kunnen aanroepers deze inspecteren.

Dat is meestal goed binnen jouw applicatie.

Maar in een openbare package-API kan verpakken implementatiedetails blootstellen als onderdeel van jouw contract.

Bijvoorbeeld, als jouw package sql.ErrNoRows verpakt, kunnen aanroepers beginnen om erop te vertrouwen:

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

Als je de opslag later wilt wijzigen, kies dan voor een domeinsentinel:

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

En geef die terug op de package-grens.

Foutgrenzen

De meest nuttige manier om te denken over Go-foutafhandeling is via grenzen.

Een grens is een plek waar een fout van betekenis of publiek verandert.

Vevoorkomen grenzen omvatten:

  • database naar repository
  • repository naar service
  • service naar HTTP-handler
  • service naar CLI-commando
  • interne fout naar gebruikersgericht bericht
  • tijdelijk falen naar herpogingsbeslissing
  • operatiefalen naar log-evenement
  • domeinfout naar API-antwoord

Foutarchitectuur is grotendeels grensontwerp. Elke grens is een beslispunt waar fouten ofwel context krijgen, implementatiedetails verliezen, of worden vertaald naar een vorm die de volgende laag kan afhandelen.

Repository-grens

De repository communiceert met de opslag.

Het zou database-specifieke fouten meestal moeten vertalen naar domeinfouten.

Voorbeeld:

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
}

De repository verbergt sql.ErrNoRows en toont ErrUserNotFound — een schone grens die betekent dat de service niets hoeft te weten over hoe opslag “niet gevonden” vertegenwoordigt.

Service-grens

De service bezit de zakelijke betekenis.

Het zou meestal operatiecontext moeten toevoegen en domeinfouten behouden.

Voorbeeld:

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
}

Dit behoudt de domeinomstandigheid terwijl het context toevoegt voor onverwachte fouten.

Voor complexere zakelijke regels kan de service domeinfouten direct maken:

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
}

De service is de juiste plek voor zakelijke fouten — direct gemaakt vanuit domeinlogica in plaats van vertaald vanuit infrastructuromstandigheden.

HTTP-handler-grens

De HTTP-handler vertaalt applicatiefouten naar HTTP-antwoorden.

Dit is een grens waar interne details gebruikersveilige antwoorden moeten worden.

Voorbeeld:

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

Foutmapping:

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

De handler mappt domeinfouten naar HTTP-semantiek in plaats van ruwe database- of interne foutdetails bloot te stellen. Dit is waar veel Go-applicaties het fout doen — ze tonen ofwel te veel interne details of ze reduceren alle fouten tot HTTP 500. Voor een compleet overzicht van handlerpatronen en middleware in Go-API’s, Building REST APIs in Go behandelt authenticatie, routing en foutafhandeling over de standaardbibliotheek, Gin, Echo en Fiber.

CLI-grens

Een CLI heeft een andere grens dan een HTTP-API.

In een CLI moet de fout nuttig zijn voor de persoon die het commando uitvoert.

Voorbeeld:

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
}

Op de commandogrens:

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

Map bekende fouten naar exitcodes:

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

Een CLI kan vaak meer details tonen dan een openbare API, maar het moet nog steeds lekken van geheimen vermijden.

API-fouttypepatroon

Voor HTTP-API’s kan een klein applicatie-niveau fouttype handig zijn.

Voorbeeld:

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
}

Constructor:

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

Gebruik:

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

Dit patroon is nuttig wanneer je gestructureerde API-fouten met stabiele codes wilt.

Gebruik het op de API-grens. Forceer niet elke interne package om API-specifieke fouten terug te geven.

Domeinfouten vs transportfouten

Houd domeinfouten gescheiden van transportfouten.

Domeinfout:

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

Transportmapping:

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

Maak niet dat je domeinlaag HTTP-statuscodes teruggeeft:

return &APIError{Status: http.StatusConflict}

Dat koppelt zakelijke logica aan HTTP en voorkomt dat je servicelaag schoon werkt over HTTP, CLI, workers, tests en toekomstige gRPC-adapters. Transportmapping hoort bij de transportgrens, niet in domeincode. Voor richtlijnen over waar domeinfouten, sentinels en transportadapters binnen jouw projectindeling te definiëren, Go Project Structure: Practices & Patterns behandelt de internal/, pkg/ en adapterconventies die deze lagen schoon gescheiden houden.

Opnieuw probeerbare fouten

Sommige fouten moeten herpogingen triggeren. Sommigen niet.

Beslis dit niet door strings te matchen.

Gebruik een marker-interface of expliciete functie.

Voorbeeld:

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

Gebruik:

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

Herhaalloop:

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

Dit is veel beter dan controleren of de foutstring “timeout” bevat — stringmatching faalt stil wanneer berichten veranderen en creëert onzichtbare koppeling tussen producer en consument.

Validatiefouten

Validatiefouten hebben vaak gestructureerde gegevens nodig.

Voorbeeld:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

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

Gebruik:

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
}

Dit is een goed gebruik van errors.As omdat de aanroeper gestructureerde informatie nodig heeft — veldnamen en validatieberichten — niet alleen een ondoorzichtige foutstring.

Meerdere fouten

Soms falen meerdere dingen.

Voorbeelden:

  • meerdere resources sluiten
  • veel velden valideren
  • meerdere workers afsluiten
  • onafhankelijke controles uitvoeren
  • output flushen en sluiten

Gebruik errors.Join wanneer alle fouten behouden moeten blijven.

Voorbeeld:

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

Aanroeper:

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

Zowel errors.Is als errors.As kunnen gecombineerde fouten inspecteren, wat betekent dat gecombineerde foutwaarden volledig compatibel blijven met standaard foutcontrolepatronen.

Wanneer errors.Join NIET te gebruiken

Gebruik errors.Join niet wanneer er één primaire fout is en wat logcontext.

Gebruik het niet om te beslissen welke fout belangrijk is.

Geef geen enorme gecombineerde fouten terug aan gebruikers.

Gecombineerde fouten zijn nuttig, maar ze kunnen snel lawaaiig worden.

Panic is geen foutafhandeling

Gebruik in normale applicatiecode geen panic voor verwachte fouten.

Slecht:

if err != nil {
	panic(err)
}

Gebruik panic voor programmeursfouten of werkelijk onherstelbare situaties.

Voorbeelden:

  • onmogelijke interne invariantenschending
  • ongeldige package-initialisatie
  • testhelper-falen met t.Fatal of panic in beperkte gevallen
  • onherstelbare startconfiguratiefout, afhankelijk van stijl

Gebruik geen panic omdat een databasequery faalde of een gebruiker ongeldige invoer indiende.

Dat zijn normale fouten.

Fouten loggen

Een veelgemaakte Go-fout is dezelfde fout op elke laag loggen.

Slecht:

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
}

Dit creëert dubbele logs voor één fout.

Beter:

  • verpak fouten terwijl ze omhoog bewegen
  • log eenmaal op de grens waar de fout wordt afgehandeld
  • voeg gestructureerde context toe in de log

Voorbeeld:

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

Dit geeft één logevenement met de volledige foutketen. Voor een productieklaar gestructureerd logging-opzet, Structured Logging in Go with slog behandelt log/slog records, JSON-handlers, contextcorrelatie en redactie — allemaal natuurlijk gekoppeld aan foutlogging op grensniveau.

Wanneer te loggen binnen lagere lagen

Log binnen lagere lagen alleen wanneer de laag de fout daadwerkelijk afhandelt of belangrijke operationele context toevoegt die elders niet zichtbaar zal zijn.

Bijvoorbeeld, een herhaalloop kan elke herhaalpoging op debug- of waarschuwingsniveau loggen.

Maar een repository zou niet elke queryfout moeten loggen als de handler de definitieve verzoekfout zal loggen.

Gebruikersgerichte fouten vs operatorfouten

Toon interne fouten niet direct aan gebruikers.

Interne fout:

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

Gebruikersgericht bericht:

internal server error

Operatorlog:

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

Dit zijn verschillende publieken, en een goede foutarchitectuur houdt ze gescheiden:

  • interne diagnostische fout
  • gebruikersveilig antwoord
  • stabiele API-foutcode
  • operatorlogcontext

Het forceren van één foutstring om al deze publieken te bedienen produceert ofwel een blootstellingsrisico of een debugnachtmerrie. Ontwerp je foutarchitectuur rondom distincte waarden voor distincte consumenten.

Beveiligde foutafhandeling

Fouten kunnen gevoelige informatie lekken.

Vermijd het blootstellen van:

  • database-connectionstrings
  • SQL-query’s met geheimen
  • interne hostnamen
  • bestandsnamen
  • accesstokens
  • API-sleutels
  • stacktraces
  • privé klantgegevens
  • autorisatiebeleid details

Dit is vooral belangrijk in HTTP-API’s.

Slecht:

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

Goed:

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

Log de interne fout veilig voor operators. Geef een veilig bericht terug aan de gebruiker.

Foutcodes

Voor openbare API’s zijn stabiele foutcodes vaak beter dan alleen vertrouwen op berichten.

Voorbeeldantwoord:

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

Het bericht kan veranderen. De code moet stabiel zijn.

Gebruik foutcodes voor:

  • cliëntgedrag
  • documentatie
  • SDK’s
  • lokalisatie
  • supportdiagnostiek

Laat cliënten geen Engelse foutberichten parseren.

Een praktisch gelaagd foutontwerp

Hier is een schoon patroon voor veel Go-backend services.

Repository-laag

  • Communiceert met database of externe opslag.
  • Converteert opslag-specifieke “niet gevonden” fouten naar domeinfouten.
  • Verpakt onverwachte opslagfouten met operatiecontext.
  • Geeft geen HTTP-fouten terug.
  • Logt meestal niet.

Voorbeeld:

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

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

Service-laag

  • Bezit zakelijke regels.
  • Maakt domeinfouten.
  • Behoudt bekende domeinfouten.
  • Verpakt onverwachte laag-niveau fouten.
  • Geeft geen HTTP-statuscodes terug.
  • Logt meestal niet.

Voorbeeld:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Transportlaag

  • Mappt domeinfouten naar HTTP, gRPC of CLI-antwoorden.
  • Logt onafgehandelde of onverwachte fouten.
  • Verbergt interne details voor gebruikers.
  • Stelt statuscodes en API-foutcodes in.

Voorbeeld:

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

Deze scheiding houdt foutafhandeling begrijpelijk en laat elke laag onafhankelijk evolueren — je kunt opslagtechnologie wijzigen zonder service-logica of transportmapping aan te raken. Het gelaagde ontwerp werkt het beste wanneer afhankelijkheden worden geïnjecteerd in plaats van hardgecodeerd; Dependency Injection in Go: Patterns & Best Practices behandelt de constructor- en interfacepatronen die elke grens makkelijk testbaar maken in isolatie.

Compleet voorbeeld

Hier is een klein end-to-end voorbeeld.

Domeinfouten:

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

Deze structuur geeft je:

  • domeinfouten
  • opslagvertaling
  • servicecontext
  • veilige HTTP-mapping
  • inspecteerbare foutketens
  • geen stringmatching
  • geen transportlekken in domeincode

Dat is de soort foutarchitectuur die schaalt — eenvoudig genoeg voor een nieuwe bijdrager om te begrijpen, maar gestructureerd genoeg dat domeinlogica nooit lekt naar transportantwoorden.

Foutgedrag testen

Foutgedrag moet net zo grondig worden getest als het gelukspad, omdat grensbeslissingen — sentinelmapping, type-extractie, HTTP-codes — vaak de plek zijn waar bugs het langst verborgen blijven. Voor een volledige gids over Go-teststructuur, mocking en dekkingspatronen, zie Go Unit Testing: Structure & Best Practices.

Test sentinelmapping

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

Test aangepaste foutextractie

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

Test HTTP-mapping

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 moeten bewijzen dat bekende fouten het juiste gedrag produceren op elke grens, zodat het refactoren van opslag- of transportlagen het falingscontract niet stil kan wijzigen.

Veelvoorkomende anti-patronen

Anti-patroon 1: Stringmatching

Slecht:

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

Gebruik errors.Is of errors.As in plaats daarvan — beide hanteren verpakte foutketens automatisch en breken niet wanneer berichten worden herschikkeld of gelokaliseerd.

Anti-patroon 2: De oorzaak verliezen

Slecht:

return errors.New("query failed")

Beter:

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

Anti-patroon 3: Verpakken zonder betekenis

Slecht:

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

Verpak met operatiecontext dat uitlegt wat er werd geprobeerd, zoals "create invoice %s: %w" in plaats van een vaag prefix dat geen diagnostische waarde toevoegt.

Anti-patroon 4: Loggen op elke laag

Slecht:

log.Println(err)
return err

op elk niveau. Log eenmaal waar de fout uiteindelijk wordt afgehandeld, niet op elke tusselaag die hem eenvoudig omhoog doorgeeft.

Anti-patroon 5: HTTP-fouten teruggeven vanuit domeincode

Slecht:

return &APIError{Status: http.StatusNotFound}

vanuit een domeinservice. Map domeinfouten naar HTTP-statuscodes en antwoordlichamen op de handler-grens, en houd je servicelaag onafhankelijk van transportzorgen.

Anti-patroon 6: Interne fouten aan gebruikers blootstellen

Slecht:

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

Geef veilige algemene berichten terug aan gebruikers en log de volledige interne fout met gestructureerde context voor operators. Stel nooit database-connectionstrings, bestandsnamen of ruwe stacktraces bloot in API-antwoorden.

Anti-patroon 7: Te veel geëxporteerde sentinels

Geëxporteerde fouten zijn onderdeel van jouw package-API en toevoegen ervan verplicht je tot het onderhouden ervan. Exporteer niet elke interne omstandigheid tenzij externe aanroepers daadwerkelijk daarop moeten takelen — houd sentinels liever niet geëxporteerd totdat er een duidelijke behoefte is.

Anti-patroon 8: Panic gebruiken voor verwachte falen

Slecht:

panic(err)

voor normale runtime-falen. Reserveer panic voor werkelijk onherstelbare omstandigheden of programmeursfouten, niet voor ontbrekende records of ongeldige gebruikersinvoer — geef altijd fouten terug in die gevallen.

Anti-patroon 9: Contextfouten negeren

Slecht:

return fmt.Errorf("request failed")

wanneer de echte oorzaak context.Canceled was. Behoud contextfouten zodat aanroepers kunnen onderscheiden tussen een werkelijke operatiefout en een geannuleerd of time-out verzoek, en adequaat kunnen reageren op elk. Voor een grondige behandeling van hoe contextannulering en time-out propagatie werken over servicelagen, zie Go context.Context Done Right.

Foutreview checklist

Gebruik deze checklist bij code review.

Foutcreatie

  • Is dit een bekende omstandigheid?
  • Zou het een sentinel moeten zijn?
  • Heeft het gestructureerde gegevens nodig?
  • Zou het een aangepast type moeten zijn?
  • Is het foutbericht duidelijk?

Foutverpakking

  • Voegt de wrap nuttige operatiecontext toe?
  • Behoudt %w de oorzaak waar nodig?
  • Stelt de code per ongeluk implementatiedetails bloot?
  • Is de keten te lawaaiig?

Foutvertaling

  • Wordt een laag-niveau fout vertaald op de juiste grens?
  • Is database-specifiek gedrag verborgen voor servicecode?
  • Zijn domeinfouten onafhankelijk van HTTP- of CLI-zorgen?

Foutafhandeling

  • Takelt de aanroeper met errors.Is of errors.As?
  • Worden contextannulering en deadlines correct afgehandeld?
  • Worden opnieuw probeerbare fouten expliciet geïdentificeerd?
  • Zijn validatiefouten gestructureerd?

Logging

  • Wordt de fout één keer gelogd, op de afhandelinggrens?
  • Zijn logs gestructureerd?
  • Worden gevoelige details uit gebruikersantwoorden uitgesloten?
  • Is er genoeg context voor operators?

Testing

  • Worden bekende foutgevalen getest?
  • Worden HTTP- of CLI-mappingen getest?
  • Worden validatiedetails getest?
  • Worden herpogingsbeslissingen getest?

Mijn eigenzinnige regels

Regel 1: Fouten moeten grenzen overschrijden met betekenis

Geef fouten niet gewoon door. Beslis wat ze betekenen op elke laag.

Regel 2: Verpak voor context, niet voor decoratie

Als verpakken geen nuttige informatie toevoegt over welke operatie faalde, verpak dan niet. Een extra laag context zonder betekenis maakt de foutketen moeilijker leesbaar en voegt geen diagnostische waarde toe.

Regel 3: Vertaal implementatiefouten naar domeinfouten

Laat sql.ErrNoRows niet onderdeel worden van je zakelijke logica. Vertaal implementatiefouten naar domeinfouten op de opslaggrens, zodat de rest van de applicatie nooit hoeft te weten welke database of ORM eronder zit.

Regel 4: Parse geen foutstrings

Als code moet takelen op falenstype, gebruik dan sentinels, aangepaste types, errors.Is of errors.As. Stringinspectie creëert onzichtbare koppeling die stil faalt wanneer foutberichten veranderen.

Regel 5: Log eenmaal

Verpak terwijl fouten omhoog bewegen. Log waar de fout uiteindelijk wordt afgehandeld.

Regel 6: Houd gebruikersberichten veilig

Interne diagnostische fouten zijn voor logs. Gebruikersgerichte berichten zijn voor gebruikers.

Regel 7: Houd transportfouten op de transportgrens

HTTP-statuscodes behoren bij handlers of API-adapters, niet in domeinservices. Domeincode moet herbruikbaar zijn over transports — vandaag HTTP, morgen CLI, gRPC of een event-gestuurde worker.

Eindgedachten

Go-foutafhandeling gaat niet over het schrijven van if err != nil voor altijd — het gaat over het expliciet en begrijpelijk maken van falen op elke grens.

De mechanica zijn eenvoudig:

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

De architectuur is het moeilijkere deel:

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

Dat is Go-foutafhandeling goed gedaan — niet slim, niet magisch, maar duidelijk genoeg zodat de volgende ontwikkelaar, operator, API-cliënt en toekomstige jij kunnen begrijpen wat faalde en wat er als volgende moet gebeuren. Voor een bredere kijk op productie Go-patronen over integratie, testing en datatoegang, zie App Architecture in Production.

Bronnen

Abonneren

Ontvang nieuwe berichten over systemen, infrastructuur en AI-engineering.