The example contains two packages, users and catalog. The primary implementation uses GORM, followed by the same error translation with the standard database/sql package.

The rule does not depend on DDD. A sentinel error belongs to the package or layer that defines its meaning for callers. That owner may be the catalog, the users package, a repository interface, or an infrastructure package. The directory layout is secondary; the public contract is what matters.

Contents

  1. How a shared ErrNotFound causes a bug
  2. How errors.Is actually works
  3. Where a sentinel error belongs
  4. Why different causes need different values
  5. Translating errors at the repository boundary
  6. GORM example
  7. The same approach without an ORM
  8. Where to map an error to an HTTP status
  9. When a sentinel is not enough
  10. Testing the error contract
  11. Practical rule
  12. References

How a shared ErrNotFound causes a bug

Consider an endpoint that displays a product. Before returning the response, the service loads both the user making the request and the product from the catalog. Either lookup can fail, but the responses differ: a missing user produces 403 Forbidden, while a missing product produces 404 Not Found.

The causes differ even though their low-level description is the same: the database returned no row.

A shared error looks convenient

A developer creates a package for reusable errors:

// internal/commonerrors/errors.go
package commonerrors

import "errors"

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

The users and catalog packages export errors with descriptive names, but assign both names the same value:

// internal/users/errors.go
package users

import "explaining_errors/internal/commonerrors"

var ErrUserNotFound = commonerrors.ErrNotFound
// internal/catalog/errors.go
package catalog

import "explaining_errors/internal/commonerrors"

var ErrProductNotFound = commonerrors.ErrNotFound

The names look tidy, but both variables refer to the same sentinel. errors.Is cannot distinguish them.

The error passes through several layers

The service adds context and returns the error to its caller:

func (s *Service) ViewProduct(
	ctx context.Context,
	viewerID int64,
	productID int64,
) (Product, error) {
	if _, err := s.users.Get(ctx, viewerID); err != nil {
		return Product{}, fmt.Errorf("load viewer %d: %w", viewerID, err)
	}

	product, err := s.catalog.Get(ctx, productID)
	if err != nil {
		return Product{}, fmt.Errorf("load product %d: %w", productID, err)
	}

	return product, nil
}

The HTTP layer selects a response from the error’s cause:

func writeProductError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, catalog.ErrProductNotFound):
		http.Error(w, "product not found", http.StatusNotFound)
	case errors.Is(err, users.ErrUserNotFound):
		http.Error(w, "access denied", http.StatusForbidden)
	default:
		http.Error(w, "internal error", http.StatusInternalServerError)
	}
}

Suppose user 42 is missing. The users repository returns users.ErrUserNotFound, the service wraps it with %w, and the HTTP layer evaluates its first condition:

errors.Is(err, catalog.ErrProductNotFound)

The result is true because catalog.ErrProductNotFound and users.ErrUserNotFound both contain the same commonerrors.ErrNotFound value. The catalog check comes first, so the endpoint returns 404 product not found. Execution never reached the product lookup.

Swapping the checks only moves the bug. A missing product would then become 403 access denied. Condition order cannot restore semantics discarded when the shared error was created.

The consequences extend beyond HTTP

Incorrect classification can affect other behavior:

  • a worker may stop retrying a temporary failure;
  • a gRPC method may return the wrong status code;
  • a metric may record the failure under the wrong category;
  • an audit event may report a missing resource instead of an access error;
  • business logic may run compensation for the wrong entity.

How errors.Is actually works

The claim that “errors.Is compares pointers” describes only one common case and can be misleading. According to the errors.Is documentation, the function walks a tree of wrapped errors. A value matches when it equals target or implements a suitable Is(error) bool method.

Two calls to errors.New create distinct values even when their text is identical:

a := errors.New("not found")
b := errors.New("not found")

fmt.Println(errors.Is(a, b)) // false

The message text is not part of the comparison. When two packages reuse one sentinel, however, both checks correctly match it:

shared := errors.New("not found")
userErr := fmt.Errorf("load user: %w", shared)

fmt.Println(errors.Is(userErr, shared)) // true

Wrapping with %w preserves the error in the chain. %v keeps only its text, so errors.Is can no longer find the original value. The official article Working with Errors in Go 1.13 explains the wrapping mechanism in detail.

Where a sentinel error belongs

The problem is neither the existence of a shared package nor the absence of DDD. It appears when one value represents several facts that require different reactions from the caller.

The package whose contract a caller examines should declare the error. Its owner is not necessarily the package that first receives the technical failure. The owner defines the distinction that makes checking the error useful.

This rule works with different application structures:

  • in a domain-oriented project, the domain package owns the error;
  • when code is grouped by product capability, the corresponding package owns it, such as catalog or checkout;
  • in a layered architecture, a repository port may declare the error.

In every case, the error forms part of the package’s public API.

Shared sentinel errors are valid when their shared meaning is intentional. The standard library uses sql.ErrNoRows when a query returns no rows and fs.ErrNotExist when a file or directory does not exist. These errors describe the contract of an abstraction, not a particular user or product.

A shared ErrNotFound also works when a service’s public contract deliberately treats every missing resource the same and all callers react identically. Creating separate values merely to mirror the directory structure adds nothing. Separate errors become necessary when the distinction changes program behavior.

An infrastructure layer may likewise have a shared storage.ErrNotFound. Before the error leaves a repository, translate it into a value the application understands.

Why different causes need different values

The users package declares its own error:

// internal/users/errors.go
package users

import "errors"

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

The catalog does the same:

// internal/catalog/errors.go
package catalog

import "errors"

var ErrProductNotFound = errors.New("product not found")

The chains are now distinct regardless of message text or wrapping depth:

productErr := fmt.Errorf("load product 100: %w", catalog.ErrProductNotFound)

fmt.Println(errors.Is(productErr, catalog.ErrProductNotFound)) // true
fmt.Println(errors.Is(productErr, users.ErrUserNotFound))      // false

The repeated errors.New syntax does not duplicate meaning. A missing user and a missing product are different application facts.

Translating errors at the repository boundary

A repository hides how data is stored. Code above it should not need to know whether the application uses GORM, database/sql, pgx, sqlc, or a remote API.

GORM example

GORM returns gorm.ErrRecordNotFound when First, Last, or Take finds no record. The GORM error-handling documentation describes this behavior.

The catalog repository translates the ORM error into its own sentinel:

func (r *ProductRepository) Get(
	ctx context.Context,
	productID int64,
) (catalog.Product, error) {
	var row productRow

	err := r.db.WithContext(ctx).
		Where("id = ?", productID).
		Take(&row).Error

	switch {
	case errors.Is(err, gorm.ErrRecordNotFound):
		return catalog.Product{}, fmt.Errorf(
			"product %d: %w",
			productID,
			catalog.ErrProductNotFound,
		)
	case err != nil:
		return catalog.Product{}, fmt.Errorf("select product %d: %w", productID, err)
	default:
		return mapProduct(row), nil
	}
}

The logs retain the product 100 context, while the caller can still check the cause with errors.Is(err, catalog.ErrProductNotFound).

UPDATE and DELETE need a separate check

In GORM’s traditional API, a missing row during UPDATE or DELETE does not produce gorm.ErrRecordNotFound. The query may succeed technically while changing zero rows, so the repository has to infer absence from RowsAffected.

Zero affected rows do not always mean that the entity is missing. The query must identify the entity unambiguously, and the behavior of RowsAffected must be known for the chosen database and driver. With optimistic locking, for example, WHERE id = ? AND version = ? may change zero rows because the version no longer matches. That is a state conflict, not necessarily not found.

When those conditions hold, the check looks like this:

func (r *ProductRepository) Archive(
	ctx context.Context,
	productID int64,
) error {
	result := r.db.WithContext(ctx).
		Model(&productRow{}).
		Where("id = ?", productID).
		Update("archived", true)

	if result.Error != nil {
		return fmt.Errorf("archive product %d: %w", productID, result.Error)
	}
	if result.RowsAffected == 0 {
		return fmt.Errorf("product %d: %w", productID, catalog.ErrProductNotFound)
	}

	return nil
}

Without the RowsAffected check, the method returns nil for a nonexistent product and the caller assumes that archiving succeeded.

A shared helper should not own application errors

Repeated translation mechanics can live in an infrastructure helper. The sentinel arrives as an argument, so the helper does not import the users or catalog packages:

func WrapNotFound(err error, resource string, target error) error {
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return fmt.Errorf("%s: %w", resource, target)
	}
	return err
}

The call remains explicit:

err := db.Where("id = ?", productID).Take(&row).Error
err = gormutil.WrapNotFound(
	err,
	fmt.Sprintf("product %d", productID),
	catalog.ErrProductNotFound,
)

The helper reuses the mechanism, while the repository still decides what the error means.

The same approach without an ORM

GORM is a database library, and the same translation works with database/sql. A query with no rows returns sql.ErrNoRows from Scan; the repository maps it to the same application contract:

func (r *ProductRepository) Get(
	ctx context.Context,
	productID int64,
) (catalog.Product, error) {
	var product catalog.Product

	err := r.db.QueryRowContext(
		ctx,
		`SELECT id, name FROM products WHERE id = $1`,
		productID,
	).Scan(&product.ID, &product.Name)

	switch {
	case errors.Is(err, sql.ErrNoRows):
		return catalog.Product{}, fmt.Errorf(
			"product %d: %w",
			productID,
			catalog.ErrProductNotFound,
		)
	case err != nil:
		return catalog.Product{}, fmt.Errorf("select product %d: %w", productID, err)
	default:
		return product, nil
	}
}

The principle is the same for UPDATE and DELETE. The affected row count comes from sql.Result:

result, err := r.db.ExecContext(
	ctx,
	`UPDATE products SET archived = TRUE WHERE id = $1`,
	productID,
)
if err != nil {
	return fmt.Errorf("archive product %d: %w", productID, err)
}

rowsAffected, err := result.RowsAffected()
if err != nil {
	return fmt.Errorf("read affected rows: %w", err)
}
if rowsAffected == 0 {
	return fmt.Errorf("product %d: %w", productID, catalog.ErrProductNotFound)
}

With pgx, sqlc, or another library, only the technical error recognized by the adapter changes. The application sentinel and checks above the repository remain the same.

Where to map an error to an HTTP status

catalog.ErrProductNotFound describes an application fact but says nothing about a particular transport. An HTTP handler can map it to 404, a gRPC adapter to codes.NotFound, and a background worker can use it to decide whether to retry.

switch {
case errors.Is(err, catalog.ErrProductNotFound):
	http.Error(w, "product not found", http.StatusNotFound)
case errors.Is(err, users.ErrUserNotFound):
	http.Error(w, "access denied", http.StatusForbidden)
default:
	http.Error(w, "internal error", http.StatusInternalServerError)
}

There is no need to create an HTTPError inside the use case. Once application code knows about 404, the transport boundary has moved into business logic. Preserve the meaningful error until it reaches an outer adapter, then choose its representation there.

When a sentinel is not enough

A sentinel works when the caller needs an answer to a binary question: did this known cause occur?

When the handler needs additional data, define a custom error type. A validation error may carry a field name, while an entity version conflict may contain the expected and actual versions. The caller can retrieve that data with errors.As.

The version conflict from the UPDATE section cannot safely be reported as not found:

// internal/catalog/errors.go
package catalog

import "fmt"

type VersionConflictError struct {
	ProductID int64
	Expected  int64
	Actual    int64
}

func (e *VersionConflictError) Error() string {
	return fmt.Sprintf(
		"product %d: version conflict, expected %d, got %d",
		e.ProductID, e.Expected, e.Actual,
	)
}

When RowsAffected is zero for an ambiguous reason, the repository returns this type. The product may be missing, or its version may have changed. A separate query distinguishes the two cases:

func (r *ProductRepository) UpdatePrice(
	ctx context.Context,
	productID int64,
	expectedVersion int64,
	price int64,
) error {
	result := r.db.WithContext(ctx).
		Model(&productRow{}).
		Where("id = ? AND version = ?", productID, expectedVersion).
		Updates(map[string]any{
			"price":   price,
			"version": expectedVersion + 1,
		})

	if result.Error != nil {
		return fmt.Errorf("update product %d: %w", productID, result.Error)
	}
	if result.RowsAffected > 0 {
		return nil
	}

	var row productRow

	err := r.db.WithContext(ctx).
		Select("version").
		Where("id = ?", productID).
		Take(&row).Error

	if errors.Is(err, gorm.ErrRecordNotFound) {
		return fmt.Errorf("product %d: %w", productID, catalog.ErrProductNotFound)
	}
	if err != nil {
		return fmt.Errorf("select product %d version: %w", productID, err)
	}

	// The product exists, so the version mismatch caused zero affected rows.
	return &catalog.VersionConflictError{
		ProductID: productID,
		Expected:  expectedVersion,
		Actual:    row.Version,
	}
}

The second query is present for the sake of the example: it shows a sentinel and a typed error in one method. Production code often omits it because it adds another database round trip to every failed update. If the caller only needs to know that a conflict occurred, there is no need to fetch the actual version. If missing products and version conflicts receive the same response, there is no need to distinguish them at all.

errors.As finds the requested type in the chain and stores it in a variable. The caller receives both the fact that a conflict occurred and the current version:

var conflict *catalog.VersionConflictError
if errors.As(err, &conflict) {
	return refetchAndRetry(ctx, conflict.Actual)
}

Do not create a separate sentinel for every identifier:

// Bad: the number of global values grows with the data.
var ErrProduct100NotFound = errors.New("product 100 not found")

The identifier belongs to the context of a particular call. Add it with fmt.Errorf while preserving the package-level sentinel through %w.

Testing the error contract

A useful test checks errors.Is behavior across package boundaries rather than comparing text:

func TestNotFoundErrorsDoNotOverlap(t *testing.T) {
	userErr := fmt.Errorf("load user 42: %w", users.ErrUserNotFound)

	if !errors.Is(userErr, users.ErrUserNotFound) {
		t.Fatal("expected user not found error")
	}
	if errors.Is(userErr, catalog.ErrProductNotFound) {
		t.Fatal("user error must not match product error")
	}
}

Test repository error translation separately:

  • gorm.ErrRecordNotFound becomes the intended sentinel;
  • sql.ErrNoRows becomes the same sentinel in an implementation without an ORM;
  • zero affected rows for UPDATE or DELETE means a missing entity only when the query identifies it by ID;
  • an unexpected database error is not disguised as not found;
  • added context does not break errors.Is.

Tests that compare err.Error() with a string lock down the message, not the error contract. A small wording change then breaks the test even though application behavior remains correct.

Practical rule

A shared error is justified only when every caller reacts to it in the same way and the owning package is prepared to support it as part of its API. Otherwise, the shared value lacks the required semantics. Keep the error with the package that defines its meaning and translate technical errors at the adapter boundary.

Checklist:

  • different causes receive different error values;
  • a repository does not expose gorm.ErrRecordNotFound or sql.ErrNoRows as its business contract;
  • add context with %w when callers must recognize the cause;
  • use %v when the inner error must deliberately remain hidden from errors.Is;
  • choose HTTP and gRPC status codes in outer adapters;
  • pass the target sentinel into a shared helper instead of letting the helper own application errors;
  • test errors.Is and errors.As, not the message text.

References