Skip to content
gohacks.dev
Basics

Domain Errors for Go Services Without Leaking Internals

Design a small set of domain errors for a Go service: a Kind type, a public message, wrapping that keeps the cause private, and mapping to front ends.

13 min read
Summarize in AI
Flat illustration of a gateway connected to a ring of services.

Most service code starts by returning whatever the layer below returned. A repository hands up a driver error, the service passes it on, and the handler turns it into a 500 because it does not recognise it. As the service grows, people add ErrNotFound here and ErrInvalid there, in different packages, with different spellings, and the handler's mapping becomes a list of special cases. Domain errors are the cure: a deliberate, small vocabulary of failure that the business logic owns and every layer shares.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article designs that vocabulary. It continues error wrapping across layers, which shows where translation happens, and feeds mapping errors to HTTP statuses. It belongs to the error handling guide. Everything ran on Go 1.25.6.

What makes an error a domain error

A domain error answers the question "what went wrong?" in terms a product manager would use. An order was not found. An email address is already registered. The caller is not allowed to refund this payment. These are facts about the business, and they are stable: the database underneath may be replaced three times and "order not found" still means the same thing.

Contrast the errors that are not domain errors. sql: no rows in result set is a fact about a library. connection refused is a fact about a network. 403 Forbidden is a fact about HTTP. Each is correct where it arises and wrong to hand upward as the service's answer, because the next layer should not have to know about the library, the network or the protocol to react.

Three properties follow.

  1. Defined with the domain, not the infrastructure. The package that holds your entities (orders, users) holds the errors too. Repositories and clients import it; it imports neither.
  2. Meaningful to the caller's decision. Each distinct error exists because a caller does something different for it: show a 404, ask the user to fix a field, retry later, give up.
  3. Free of internals. The text a user might see contains nothing about tables, hosts or stack traces.

A single type with a Kind

You can build the vocabulary from sentinels (ErrNotFound, ErrConflict) or from a type per case. For a service with a handful of failure classes, the least ceremony is one struct and an enumerated Kind, an approach familiar from several well-known Go codebases. The type carries what every domain error needs: its class, a message safe to show, and the underlying cause for the logs.

errs.goGo
package main

import (
	"errors"
	"fmt"
)

// Kind classifies a domain error by what a caller should do about it.
type Kind int

const (
	KindInternal    Kind = iota // unexpected: show nothing specific
	KindNotFound                // the thing does not exist
	KindInvalid                 // the input is wrong and the caller can fix it
	KindConflict                // the request clashes with current state
	KindForbidden               // the caller is known and not allowed
	KindUnavailable             // try again later
)

func (k Kind) String() string {
	switch k {
	case KindNotFound:
		return "not found"
	case KindInvalid:
		return "invalid"
	case KindConflict:
		return "conflict"
	case KindForbidden:
		return "forbidden"
	case KindUnavailable:
		return "unavailable"
	}
	return "internal"
}

// Error is the service's one domain error type.
type Error struct {
	Kind   Kind
	Public string // safe to show a user
	Err    error  // the private cause, for logs; may be nil
}

func (e *Error) Error() string {
	if e.Err == nil {
		return fmt.Sprintf("%s: %s", e.Kind, e.Public)
	}
	return fmt.Sprintf("%s: %s: %v", e.Kind, e.Public, e.Err)
}

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

// E builds a domain error.
func E(kind Kind, public string, cause error) *Error {
	return &Error{Kind: kind, Public: public, Err: cause}
}

// KindOf returns the Kind of the first domain error in err's chain.
func KindOf(err error) Kind {
	var de *Error
	if errors.As(err, &de) {
		return de.Kind
	}
	return KindInternal
}

// PublicMessage returns text that is safe to show a user.
func PublicMessage(err error) string {
	var de *Error
	if errors.As(err, &de) && de.Kind != KindInternal {
		return de.Public
	}
	return "something went wrong"
}

The zero Kind is KindInternal on purpose. An error value that nobody classified, or an error that is not a domain error at all, means "unexpected", and every consumer treats it that way by default. The default failing safe is the same instinct as the 500 fallback in the HTTP adapter.

Public and Err are separate fields because they have different audiences. A user sees Public. An engineer sees Err in the log. Nothing computes the one from the other, which is what stops a driver message from sneaking into a response. KindOf and PublicMessage use errors.As, so they work through any amount of wrapping with %w.

Using it from a repository and a service

The repository translates what it knows into domain errors and keeps the driver's error as the private cause. The service adds context and decides some kinds itself, like validation:

main.goGo
package main

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

type User struct {
	ID    int
	Email string
}

func findUser(id int) (User, error) {
	var err error
	switch id {
	case 1:
		return User{1, "[email protected]"}, nil
	case 2:
		err = sql.ErrNoRows
	default:
		err = errors.New("pq: connection refused (host db-primary-3)")
	}
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, E(KindNotFound, "user not found", err)
	}
	return User{}, E(KindUnavailable, "the service is busy, try again", err)
}

func rename(id int, email string) error {
	if !strings.Contains(email, "@") {
		return E(KindInvalid, "email must contain @", nil)
	}
	if _, err := findUser(id); err != nil {
		return fmt.Errorf("rename user %d: %w", id, err)
	}
	return nil
}

func main() {
	for _, tc := range []struct {
		id    int
		email string
	}{{1, "[email protected]"}, {2, "[email protected]"}, {3, "[email protected]"}, {1, "bad"}} {
		err := rename(tc.id, tc.email)
		if err == nil {
			fmt.Println("renamed")
			continue
		}
		fmt.Printf("kind=%-11v public=%q\n", KindOf(err), PublicMessage(err))
		fmt.Println("   log:", err)
	}

	for _, k := range []Kind{KindNotFound, KindInvalid, KindConflict, KindForbidden, KindUnavailable, KindInternal} {
		fmt.Printf("%-12v http=%d exit=%d\n", k, httpStatus(k), exitCode(k))
	}

	wrapped := fmt.Errorf("load order: %w", E(KindNotFound, "order not found", nil))
	fmt.Println(errors.Is(wrapped, ErrNotFound), errors.Is(wrapped, &Error{Kind: KindConflict}))
}
Text
renamed
kind=not found   public="user not found"
   log: rename user 2: not found: user not found: sql: no rows in result set
kind=unavailable public="the service is busy, try again"
   log: rename user 3: unavailable: the service is busy, try again: pq: connection refused (host db-primary-3)
kind=invalid     public="email must contain @"
   log: invalid: email must contain @
not found    http=404 exit=66
invalid      http=422 exit=64
conflict     http=409 exit=1
forbidden    http=403 exit=1
unavailable  http=503 exit=69
internal     http=500 exit=1
true false

Look at the third result. The database is down, the host name is in the cause, and the public message says only that the service is busy. The log line carries the whole chain, including db-primary-3, and the user never sees it. The second result is a not-found that carries sql: no rows as its private cause: useful to someone debugging, invisible to callers who test KindOf(err) == KindNotFound. The first call succeeds and prints renamed; the helpers are only for non-nil errors, so a caller never asks for the kind of a success. The last block of the output is the mapping table and the Is check, which the sections below explain.

Mapping Kind to the outside

Once failure has a small closed vocabulary, each front end is one switch:

map.goGo
package main

import "net/http"

func httpStatus(k Kind) int {
	switch k {
	case KindNotFound:
		return http.StatusNotFound
	case KindInvalid:
		return http.StatusUnprocessableEntity
	case KindConflict:
		return http.StatusConflict
	case KindForbidden:
		return http.StatusForbidden
	case KindUnavailable:
		return http.StatusServiceUnavailable
	}
	return http.StatusInternalServerError
}

func exitCode(k Kind) int {
	switch k {
	case KindInvalid:
		return 64
	case KindNotFound:
		return 66
	case KindUnavailable:
		return 69
	}
	return 1
}

The same six kinds produce HTTP statuses for the server and sysexits-style codes for a command line tool. A gRPC server would add a third switch to codes.NotFound, codes.InvalidArgument, codes.AlreadyExists and so on. Nothing in the service changes. Adding a front end is writing one function, and the compiler cannot check it is exhaustive, so a test that loops over every kind is the guard.

Kinds versus sentinels

Why a Kind and not ErrNotFound? Either works, and each has a place. Sentinels are better when callers test for one condition and the condition carries no data; errors.Is(err, ErrNotFound) reads well. A Kind is better when you want one type that carries a message and a cause alongside the class, and when front ends switch over the whole set. The two combine: give Error an Is method so errors.Is(err, ErrNotFound) is true for any domain error of that kind, and both styles work:

is.goGo
package main

var ErrNotFound = &Error{Kind: KindNotFound}

func (e *Error) Is(target error) bool {
	t, ok := target.(*Error)
	return ok && t.Kind == e.Kind && t.Public == "" && t.Err == nil
}

A bare *Error with only a kind acts as a wildcard for its class, so ErrNotFound matches any not-found domain error. It is the same technique as in custom error types, applied to a whole family.

How many kinds

Fewer than you think. The test for a new kind is whether some caller does something different for it. If two kinds lead to the same handling everywhere, merge them. Most services settle on not found, invalid, conflict, unauthorized or forbidden, unavailable and internal; a few add rate limited and a precondition-failed class. Resist kinds named for the feature, such as KindPaymentDeclined. That is data the business cares about, so it belongs in a field or a specific type that implements Unwrap to a KindConflict error. The kinds are for plumbing, and the details are for the people who need them.

Public messages

The text in Public is user-facing copy, and that has consequences. Keep it short, written for a person, and free of identifiers that would help an attacker: "invalid email or password" and not "no such user", for example, so a login form does not tell a stranger which addresses are registered. If your product is localised, Public is the wrong place for the final string, because the domain should not know the user's language; carry a stable code ("user.not_found") and let the transport layer translate it (localized errors covers this). And never build Public from the cause with err.Error(), which is the leak the whole design exists to prevent.

Constructors that read well

Calling E(KindNotFound, "user not found", err) at every site gets repetitive, and the repetition invites inconsistent wording: "user not found" in one place, "no such user" in the next. A few helper constructors fix both, one per kind that has a standard message shape:

helpers.goGo
package main

import "fmt"

// NotFound reports that the named thing does not exist.
func NotFound(what string, cause error) *Error {
	return E(KindNotFound, what+" not found", cause)
}

// Invalid reports a problem the caller can fix.
func Invalid(format string, args ...any) *Error {
	return E(KindInvalid, fmt.Sprintf(format, args...), nil)
}

// Unavailable reports a transient failure and keeps the cause for the log.
func Unavailable(cause error) *Error {
	return E(KindUnavailable, "the service is busy, try again", cause)
}

Now the repository reads return User{}, NotFound("user", err), and the wording lives in one place. Keep the constructors few and boring: if you find yourself writing twenty of them, the domain probably wants a specific error type for the awkward ones. Constructors also give you one place to add things later, such as a request ID, a timestamp or a metrics counter keyed by kind, without touching the call sites.

Domain errors and the rest of the standard toolkit

The Error type is an ordinary Go error, and it composes with everything covered elsewhere in this series. It wraps with %w like any other, so fmt.Errorf("rename user %d: %w", id, err) adds context without hiding the kind. It works with errors.Join for batch operations, where KindOf reports the first domain error in the group; if you need the most severe, walk the members yourself (collecting multiple errors). It can be retried or not by asking its kind: a retry helper that retries only KindUnavailable and gives up on the rest needs no knowledge of the service (retryable and permanent errors). And it survives a goroutine boundary, since it travels as an ordinary value on an error channel (returning errors from goroutines).

The type does not replace specific error types where callers need data. A validation failure with several problems is better as a *ValidationError with a list of fields, and that type can implement Unwrap to return an Error of KindInvalid, so both the class and the details are reachable. The domain error is the common currency, and specific types are the itemised receipts.

Evolving the vocabulary

A domain error set is API, even inside one repository, because every front end switches over it. Treat additions as changes. Add a kind only with a caller that needs it, and add the mapping in every front end in the same change. Never repurpose a kind: if KindConflict meant "duplicate" and you start using it for "stale version", old callers that retried on it now misbehave. Retire a kind by leaving the constant in place and documenting that nothing returns it, so numeric values stored in logs or metrics keep their meaning.

If the set seems to be growing past a dozen, look for a missing split. Two services in one binary may want separate vocabularies, with an adapter between them, and the right move is two small sets, not one large one. The opposite sign is a set where half the kinds are never inspected: those can be merged into KindInternal without losing anything a caller used.

Other designs worth knowing

The single-type design is not the only one. Some teams define one type per error (*NotFoundError, *ConflictError) and have front ends use errors.As against each; that scales when each error carries its own data, and it makes the front-end switch longer. Others define an interface, Kind() Kind, that any error can implement, which lets specific types join the vocabulary without being a single struct; the front end then calls errors.As with the interface. Large projects such as Kubernetes expose helper predicates (IsNotFound(err)) and keep the type private, which is the opaque style from error design. All of these agree on the principle: the layers share a small vocabulary that the domain owns, and the front ends translate it. Pick the form that fits your team's habits, and keep the vocabulary small.

Common mistakes

Putting HTTP in the domain. A field named StatusCode on the domain error brings transport into the core, and it is the first thing a second front end will need to work around. Keep the code in the mapping.

One kind for everything. A service that returns KindInternal for all failures has a type and no vocabulary. The point is to let callers distinguish, so classify at the first place the cause is understood.

Leaking through Error(). Our Error() includes the cause, deliberately, because it is for logs. If any code sends err.Error() to a client, the design fails. Make the rule that responses use PublicMessage only, and enforce it with the leak test above or a linter rule that flags err.Error() in handlers.

Classifying too late. If the handler decides that a driver error means "not found", every handler must repeat that logic. The layer that understands the cause classifies it, which is why the repository translates.

Treating the kind as the whole story. A KindConflict tells the handler to answer 409, and the message and cause tell the human why. Both matter, and the design keeps them apart so each can be used in the right place.

Testing the contract

Two tests protect the design. One loops over every Kind and asserts each front end handles it, so adding a kind without updating the switches fails. The other is a leak test: take a domain error whose private cause contains a secret-looking string, pass it through PublicMessage, and assert the secret is absent.

errs_test.goGo
package main

import (
	"errors"
	"fmt"
	"net/http"
	"strings"
	"testing"
)

func TestEveryKindHasAnHTTPStatus(t *testing.T) {
	for k := KindInternal; k <= KindUnavailable; k++ {
		status := httpStatus(k)
		if k != KindInternal && status == http.StatusInternalServerError {
			t.Errorf("kind %v falls through to 500; add it to httpStatus", k)
		}
	}
}

func TestPublicMessageNeverLeaksTheCause(t *testing.T) {
	secret := "password=hunter2 host=db-primary-3"
	for _, k := range []Kind{KindNotFound, KindInvalid, KindUnavailable, KindInternal} {
		err := fmt.Errorf("op: %w", E(k, "safe text", errors.New(secret)))
		if got := PublicMessage(err); strings.Contains(got, "hunter2") || strings.Contains(got, "db-primary") {
			t.Errorf("kind %v leaked the cause: %q", k, got)
		}
	}
}

func TestKindSurvivesWrapping(t *testing.T) {
	err := fmt.Errorf("a: %w", fmt.Errorf("b: %w", NotFound("order", nil)))
	if KindOf(err) != KindNotFound {
		t.Errorf("KindOf = %v, want not found", KindOf(err))
	}
}
Text
=== RUN   TestEveryKindHasAnHTTPStatus
--- PASS: TestEveryKindHasAnHTTPStatus (0.00s)
=== RUN   TestPublicMessageNeverLeaksTheCause
--- PASS: TestPublicMessageNeverLeaksTheCause (0.00s)
=== RUN   TestKindSurvivesWrapping
--- PASS: TestKindSurvivesWrapping (0.00s)
PASS
ok  	example.test/lab	1.293s

The first test is the one that earns its keep over time: it fails the day someone adds a kind and forgets the mapping. The second is a few lines and prevents the most damaging regression. The third pins the behavior that makes the whole approach work, which is that wrapping on the way up does not hide the class. For the underlying tools, see errors Is and As, and for the language-level reference the Go blog's Working with Errors in Go 1.13 and the errors package.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.