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/sqland checks forsql.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/sqlandredisand maps the same storage errors to a different output format (codes.NotFoundinstead ofhttp.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)
%wwrapsuser.ErrNotFound- the domain sentinel, not the originalsql.ErrNoRows. The repository catchessql.ErrNoRowsin theifcheck above, but instead of wrapping it, builds a new error arounduser.ErrNotFound. Soerrors.Is(err, user.ErrNotFound)matches, buterrors.Is(err, sql.ErrNoRows)does not because that error was consumed here, not wrapped. The message"user 42 not in db: not found"still tells you what happened during debugging. - (2)
%vwraps the rawerrfromdatabase/sql. This is a storage error that callers shouldn’t be able to inspect programmatically.%vpreserves the error message for logging but severs the chain, soerrors.Is(err, sql.ErrWhatever)won’t match. If I used%where, callers coulderrors.Isthrough todatabase/sqltypes 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 becomesuser.ErrConflictwrapped with%wand 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
%vwrapping, 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
ErrNotFoundwith%wand the user ID.%wis appropriate here becauseErrNotFoundis the service’s own error, not a leaked storage detail. The handler can still match it witherrors.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)
ErrNotFoundbecomes 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)
ErrConflictbecomes 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.
The standard library does the same thing #
The os package translates platform-specific errors into portable ones. On Linux, opening a
missing file fails with syscall.ENOENT. On Windows, it fails with ERROR_FILE_NOT_FOUND.
But callers never see either:
// Example usage
f, err := os.Open("/etc/missing.yaml")
if errors.Is(err, fs.ErrNotExist) {
// same check works on Linux, macOS, and Windows
}
os.Open catches the platform error and wraps it so that errors.Is maps it to
fs.ErrNotExist
. Same idea as the repository catching sql.ErrNoRows and wrapping
user.ErrNotFound instead.
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 callers
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.