Error translation in Go services

Table of contents

In a layered Go service, it’s easy to accidentally leak storage errors like sql.ErrNoRows all the way up to the handler, or worse, to the client. This post shows how to catch those at the service boundary, translate them into domain errors, and keep internal details from reaching places they shouldn’t.

When the handler knows your database #

Say you have a user service backed by Postgres. The handler fetches a user by ID and needs to distinguish “not found” from an actual failure:

// handler.go

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    u, err := h.svc.GetUser(r.Context(), id)
    if errors.Is(err, sql.ErrNoRows) { // (1)
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(u)
}
  • (1) This is the coupling. The handler imports database/sql and checks for sql.ErrNoRows, a storage-specific error. The handler now knows the service uses SQL.

For a small service with one database and one transport, that’s a reasonable tradeoff. You know it’s SQL, and nothing else is going to change anytime soon.

Then the service grows. Someone puts Redis in front of Postgres as a read-through cache, and now there are two different “not found” errors:

// handler.go

if errors.Is(err, sql.ErrNoRows) || errors.Is(err, redis.Nil) {
    http.Error(w, "not found", http.StatusNotFound)
    return
}

The handler now imports two storage packages. It knows the service uses both Postgres and Redis. Then you add soft deletes. A soft-deleted user exists in both Postgres and Redis, so neither sql.ErrNoRows nor redis.Nil fires for it. But the service considers the user gone. The handler has no way to return 404 for this case because neither storage error applies.

Then someone adds a gRPC handler for the same service:

// handler.go

func (h *Handler) GetUser(
    ctx context.Context, req *pb.GetUserRequest,
) (*pb.GetUserResponse, error) {
    u, err := h.svc.GetUser(ctx, req.GetId())
    if errors.Is(err, sql.ErrNoRows) || errors.Is(err, redis.Nil) { // (1)
        return nil, status.Error(codes.NotFound, "not found")
    }
    if err != nil {
        return nil, status.Error(codes.Internal, "internal error")
    }
    return &pb.GetUserResponse{
        Id: u.ID, Name: u.Name, Email: u.Email,
    }, nil
}
  • (1) The same storage error checks from the HTTP handler, duplicated here. The gRPC handler also imports database/sql and redis and maps the same storage errors to a different output format (codes.NotFound instead of http.StatusNotFound).

Now two handlers know about sql.ErrNoRows and redis.Nil. Adding a third storage backend or removing Redis means updating both. Every change to storage ripples into transport code that shouldn’t care how data is stored.

The handler shouldn’t need to know any of this. It should check for a single “not found” error and return 404 regardless of whether the cause was a missing SQL row, a Redis miss, or a soft delete. That means the service needs its own error types.

Defining domain errors #

When sql.ErrNoRows passes through the service and reaches the handler, it becomes part of the interface between those layers. Swap Postgres for DynamoDB and the handler breaks, defeating the whole purpose of having a repository layer in between. The service package can prevent this by defining errors that describe what went wrong in business terms:

// user/user.go

package user

import (
    "context"
    "errors"
    "time"
)

type User struct {
    ID        int64      `json:"id"`
    Name      string     `json:"name"`
    Email     string     `json:"email"`
    DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

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

type Store interface {
    Get(ctx context.Context, id int64) (User, error)
    Create(ctx context.Context, u User) (int64, error)
}

ErrNotFound means the user doesn’t exist. It doesn’t say why. A missing SQL row, an expired Redis key, and a soft-deleted record all produce the same error. The handler doesn’t need to distinguish between these cases because in all three, the response is a 404.

ErrConflict means a uniqueness constraint would be violated. Whether that’s a SQL UNIQUE index or a DynamoDB conditional check is for the storage package to worry about.

With these defined, the repository is where the mapping happens: catch storage-specific errors and return domain errors instead.

Catching storage errors in the repository #

Here’s the SQLite implementation of the repository interface. The two error paths handle things differently on purpose:

// sqlite/store.go

func (s *UserStore) Get(
    ctx context.Context, id int64,
) (user.User, error) {
    row := s.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = ?", id)

    var u user.User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return user.User{}, fmt.Errorf(
                "user %d not in db: %w", id, user.ErrNotFound, // (1)
            )
        }
        return user.User{}, fmt.Errorf(
            "querying user %d: %v", id, err, // (2)
        )
    }
    return u, nil
}

The two paths use different format verbs and wrap different things:

  • (1) %w wraps user.ErrNotFound, which is a domain error that this package owns. Callers can errors.Is(err, user.ErrNotFound) to check for it, and the message "user 42 not in db: not found" tells you where the error came from during debugging. The original sql.ErrNoRows is gone from the chain entirely. Nothing above the repository can reach it.
  • (2) %v wraps the raw err from database/sql. This is a storage error that callers shouldn’t be able to inspect programmatically. %v preserves the error message for logging but severs the chain, so errors.Is(err, sql.ErrWhatever) won’t match. If I used %w here, callers could errors.Is through to database/sql types and the coupling would come back. I wrote more about this choice in Go errors: to wrap or not to wrap? .

The rule is: use %w for your own domain errors (callers should inspect them), %v for storage errors (callers shouldn’t).

For creates, constraint violations get the same treatment:

// sqlite/store.go

func (s *UserStore) Create(
    ctx context.Context, u user.User,
) (int64, error) {
    res, err := s.db.ExecContext(ctx,
        "INSERT INTO users (name, email) VALUES (?, ?)",
        u.Name, u.Email,
    )
    if err != nil {
        if sqliteErr, ok := errors.AsType[sqlite3.Error](err); ok &&
            sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
            return 0, fmt.Errorf(
                "user %s already exists: %w", // (1)
                u.Email, user.ErrConflict,
            )
        }
        return 0, fmt.Errorf("inserting user: %v", err) // (2)
    }
    return res.LastInsertId()
}
  • (1) Same pattern as Get. A database-specific constraint error becomes user.ErrConflict wrapped with %w and the conflicting email for debugging context. The handler sees “conflict” and returns 409. It doesn’t know which database or which constraint was violated.
  • (2) Unknown errors get %v wrapping, same as before. The message is preserved for logging but the chain is severed.

The service layer doesn’t need to do any mapping of its own. It passes domain errors from the store straight through. When it has business reasons to produce the same error independently, it uses the same sentinel:

// user/service.go

func (s *Service) GetUser(
    ctx context.Context, id int64,
) (User, error) {
    u, err := s.store.Get(ctx, id)
    if err != nil {
        return User{}, err // (1)
    }
    if u.DeletedAt != nil {
        return User{}, fmt.Errorf(
            "user %d soft-deleted: %w", id, ErrNotFound, // (2)
        )
    }
    return u, nil
}
  • (1) If the store returned ErrNotFound (missing row), it passes through unchanged. The service doesn’t translate anything here because the error is already in domain terms.
  • (2) A soft-deleted user exists in the database but is logically gone. The service wraps ErrNotFound with %w and the user ID. %w is appropriate here because ErrNotFound is the service’s own error, not a leaked storage detail. The handler can still match it with errors.Is(err, ErrNotFound).

Important

You don’t need to translate at every layer. The repository maps storage errors to domain errors. The handler maps domain errors to wire format. The service layer in between just passes domain errors through unchanged. Two translation points, not one per layer.

Once the repository handles the storage-to-domain mapping, the handler gets much simpler.

Mapping domain errors to status codes #

Compare this to the handler from the beginning of the post. No database/sql import, no redis import, no knowledge of which storage backends exist:

// main.go

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)

    u, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        writeError(w, err)
        return
    }
    json.NewEncoder(w).Encode(u)
}

func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    json.NewDecoder(r.Body).Decode(&req)

    u, err := h.svc.CreateUser(r.Context(), req.Name, req.Email)
    if err != nil {
        writeError(w, err)
        return
    }
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(u)
}

All error-to-status mapping lives in one function. Domain errors go in, HTTP status codes come out:

// main.go

func writeError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, user.ErrNotFound):
        http.Error(w, "not found", http.StatusNotFound) // (1)
    case errors.Is(err, user.ErrConflict):
        http.Error(w, "conflict", http.StatusConflict) // (2)
    default:
        http.Error(w, "internal error", http.StatusInternalServerError) // (3)
    }
}
  • (1) ErrNotFound becomes 404. The handler doesn’t know if it was a SQL miss, a Redis miss, or a soft delete. It doesn’t need to.
  • (2) ErrConflict becomes 409. The handler doesn’t know which constraint was violated.
  • (3) Anything else becomes 500 with a generic message. No internal details leak to the client.

The gRPC handler uses the same service with a different mapping function:

// main.go

func toStatus(err error) error {
    switch {
    case errors.Is(err, user.ErrNotFound):
        return status.Error(codes.NotFound, "not found") // 404 equivalent
    case errors.Is(err, user.ErrConflict):
        return status.Error(codes.AlreadyExists, "conflict") // 409 equivalent
    default:
        return status.Error(codes.Internal, "internal error")
    }
}
// main.go

func (h *handler) GetUser(
    ctx context.Context, req *api.GetUserRequest,
) (*api.GetUserResponse, error) {
    u, err := h.svc.GetUser(ctx, req.GetId())
    if err != nil {
        return nil, toStatus(err)
    }
    return &api.GetUserResponse{
        Id: u.ID, Name: u.Name, Email: u.Email,
    }, nil
}

func (h *handler) CreateUser(
    ctx context.Context, req *api.CreateUserRequest,
) (*api.CreateUserResponse, error) {
    u, err := h.svc.CreateUser(
        ctx, req.GetName(), req.GetEmail(),
    )
    if err != nil {
        return nil, toStatus(err)
    }
    return &api.CreateUserResponse{Id: u.ID}, nil
}

writeError and toStatus have the same shape. One outputs HTTP status codes, the other outputs gRPC status codes. The service behind both is identical. If you add a new error like ErrForbidden, you define one sentinel in the user package and add one case to each mapping function.

What you lose and how to get it back #

When the handler sees ErrNotFound, it doesn’t know whether that was a SQL miss, a Redis miss, or a soft delete. That’s the whole point of the translation, but during an incident you need that information.

This is why the repository and service wrap ErrNotFound with descriptive context using %w, as shown above. The repository produces "user 42 not in db: not found" and the service produces "user 42 soft-deleted: not found". Same domain error, different origin. The handler treats both as 404, but the error strings are distinct.

To make this useful, the handler logs the full error before returning the response:

// main.go

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)

    u, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        slog.ErrorContext(r.Context(), "get user failed",
            "user_id", id,
            "err", err,
        )
        writeError(w, err) // responds with "not found", not err.Error()
        return
    }
    json.NewEncoder(w).Encode(u)
}

The client sees a 404 with the body not found. The on-call engineer sees this:

level=ERROR msg="get user failed" user_id=42
    err="user 42 not in db: not found"

The error string tells you which code path produced the error. If you have tracing set up, the request-scoped context carries the trace ID too, so you can follow the 404 all the way back to the storage call that failed.

Precedent in the standard library #

This pattern shows up in several places in the standard library. The os package is the clearest example. On Linux, opening a file that doesn’t exist returns syscall.ENOENT. On Windows, the same operation returns syscall.ERROR_FILE_NOT_FOUND. If callers had to check for every platform-specific syscall error, any code that opens a file would be full of OS-specific branches.

The os package translates at the boundary. The Is method on syscall.Errno maps each platform error number to a portable fs.Err* sentinel:

// From syscall/syscall_unix.go (simplified)

func (e Errno) Is(target error) bool {
    switch target {
    case oserror.ErrPermission:
        return e == EACCES || e == EPERM // (1)
    case oserror.ErrExist:
        return e == EEXIST || e == ENOTEMPTY
    case oserror.ErrNotExist:
        return e == ENOENT // (2)
    }
    return false
}
  • (1) Multiple platform-specific errors (EACCES, EPERM) map to one portable sentinel (oserror.ErrPermission). Same idea as mapping both sql.ErrNoRows and redis.Nil to a single ErrNotFound.
  • (2) ENOENT (Linux “no such file”) maps to oserror.ErrNotExist, which is the same value as fs.ErrNotExist. On Windows, a different Errno.Is maps ERROR_FILE_NOT_FOUND to the same sentinel. Callers check one error, works everywhere.

When you call os.Open on a missing file, the os package wraps the syscall error in a *fs.PathError:

// Example usage

f, err := os.Open("/etc/missing.yaml")
// err is &fs.PathError{
//     Op:   "open",
//     Path: "/etc/missing.yaml",
//     Err:  syscall.ENOENT,
// }

errors.Is(err, fs.ErrNotExist) traverses the chain. *fs.PathError implements Unwrap(), which returns syscall.ENOENT. Then errors.Is calls ENOENT.Is(fs.ErrNotExist), which hits the switch above and returns true:

// Example usage

if errors.Is(err, fs.ErrNotExist) {
    // works on Linux, macOS, and Windows
}

database/sql does a similar thing. When QueryRow finds no matching rows, the underlying driver signals end of results through its own internal protocol. Row.Scan catches that condition and returns sql.ErrNoRows:

// From database/sql/sql.go (simplified)

func (r *Row) Scan(dest ...any) error {
    if r.err != nil {
        return r.err
    }
    defer r.rows.Close()

    if !r.rows.Next() { // (1)
        if err := r.rows.Err(); err != nil {
            return err
        }
        return ErrNoRows // (2)
    }
    // ...
}
  • (1) r.rows.Next() calls into the database driver. When there are no rows, the driver signals end-of-results through its own protocol. That signal never reaches the caller.
  • (2) Scan returns sql.ErrNoRows instead. The driver said “end of result set” but database/sql says “your query matched no rows.” Callers check one error regardless of whether Postgres, MySQL, or SQLite is behind the connection.

etcd’s clientv3 package does the same translation in the reverse direction. The client receives gRPC status codes from the server and maps them into plain Go errors so that users never import google.golang.org/grpc/status. I covered this in Wrapping a gRPC client in Go .


Working examples for the HTTP version and the gRPC version are on GitHub, in the error-translation directory.

§