{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/error-translation/",
"description": "Translating errors at layer boundaries so storage details don't leak into the handler or, worse, into client responses.",
"path": "/go/error-translation/",
"publishedAt": "2026-04-12T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Error Handling"
],
"textContent": "In a layered Go service, it's easy to accidentally leak storage errors like sql.ErrNoRows\nall the way up to the handler, or worse, to the client. This post shows how to catch those\nat the service boundary, translate them into domain errors, and keep internal details from\nreaching places they shouldn't.\n\nWhen the handler knows your database\n\nSay you have a user service backed by Postgres. The handler fetches a user by ID and needs\nto distinguish \"not found\" from an actual failure:\n\n- (1) This is the coupling. The handler imports database/sql and checks for\n sql.ErrNoRows, a storage-specific error. The handler now knows the service uses SQL.\n\nFor a small service with one database and one transport, that's a reasonable tradeoff. You\nknow it's SQL, and nothing else is going to change anytime soon.\n\nThen the service grows. Someone puts Redis in front of Postgres as a read-through cache, and\nnow there are two different \"not found\" errors:\n\nThe handler now imports two storage packages. It knows the service uses both Postgres and\nRedis. Then you add soft deletes. A soft-deleted user exists in both Postgres and Redis, so\nneither sql.ErrNoRows nor redis.Nil fires for it. But the service considers the user\ngone. The handler has no way to return 404 for this case because neither storage error\napplies.\n\nThen someone adds a gRPC handler for the same service:\n\n- (1) The same storage error checks from the HTTP handler, duplicated here. The gRPC handler\n also imports database/sql and redis and maps the same storage errors to a different\n output format (codes.NotFound instead of http.StatusNotFound).\n\nNow two handlers know about sql.ErrNoRows and redis.Nil. Adding a third storage backend\nor removing Redis means updating both. Every change to storage ripples into transport code\nthat shouldn't care how data is stored.\n\nThe handler shouldn't need to know any of this. It should check for a single \"not found\"\nerror and return 404 regardless of whether the cause was a missing SQL row, a Redis miss, or\na soft delete. That means the service needs its own error types.\n\nDefining domain errors\n\nWhen sql.ErrNoRows passes through the service and reaches the handler, it becomes part of\nthe interface between those layers. Swap Postgres for DynamoDB and the handler breaks,\ndefeating the whole purpose of having a repository layer in between. The service package can\nprevent this by defining errors that describe what went wrong in business terms:\n\nErrNotFound means the user doesn't exist. It doesn't say why. A missing SQL row, an\nexpired Redis key, and a soft-deleted record all produce the same error. The handler doesn't\nneed to distinguish between these cases because in all three, the response is a 404.\n\nErrConflict means a uniqueness constraint would be violated. Whether that's a SQL UNIQUE\nindex or a DynamoDB conditional check is for the storage package to worry about.\n\nWith these defined, the repository is where the mapping happens: catch storage-specific\nerrors and return domain errors instead.\n\nCatching storage errors in the repository\n\nHere's the SQLite implementation of the repository interface. The two error paths handle\nthings differently on purpose:\n\nThe two paths use different format verbs and wrap different things:\n\n- (1) %w wraps user.ErrNotFound - the domain sentinel, not the original sql.ErrNoRows.\n The repository catches sql.ErrNoRows in the if check above, but instead of wrapping\n it, builds a new error around user.ErrNotFound. So errors.Is(err, user.ErrNotFound)\n matches, but errors.Is(err, sql.ErrNoRows) does not because that error was consumed\n here, not wrapped. The message \"user 42 not in db: not found\" still tells you what\n happened during debugging.\n- (2) %v wraps the raw err from database/sql. This is a storage error that callers\n shouldn't be able to inspect programmatically. %v preserves the error message for\n logging but severs the chain, so errors.Is(err, sql.ErrWhatever) won't match. If I used\n %w here, callers could errors.Is through to database/sql types and the coupling\n would come back. I wrote more about this choice in [Go errors: to wrap or not to wrap?].\n\nThe rule is: use %w for your own domain errors (callers should inspect them), %v for\nstorage errors (callers shouldn't).\n\nFor creates, constraint violations get the same treatment:\n\n- (1) Same pattern as Get. A database-specific constraint error becomes user.ErrConflict\n wrapped with %w and the conflicting email for debugging context. The handler sees\n \"conflict\" and returns 409. It doesn't know which database or which constraint was\n violated.\n- (2) Unknown errors get %v wrapping, same as before. The message is preserved for logging\n but the chain is severed.\n\nThe service layer doesn't need to do any mapping of its own. It passes domain errors from\nthe store straight through. When it has business reasons to produce the same error\nindependently, it uses the same sentinel:\n\n- (1) If the store returned ErrNotFound (missing row), it passes through unchanged. The\n service doesn't translate anything here because the error is already in domain terms.\n- (2) A soft-deleted user exists in the database but is logically gone. The service wraps\n ErrNotFound with %w and the user ID. %w is appropriate here because ErrNotFound is\n the service's own error, not a leaked storage detail. The handler can still match it with\n errors.Is(err, ErrNotFound).\n\n> [!IMPORTANT]\n>\n> You don't need to translate at every layer. The repository maps storage errors to domain\n> errors. The handler maps domain errors to wire format. The service layer in between just\n> passes domain errors through unchanged. Two translation points, not one per layer.\n\nOnce the repository handles the storage-to-domain mapping, the handler gets much simpler.\n\nMapping domain errors to status codes\n\nCompare this to the handler from the beginning of the post. No database/sql import, no\nredis import, no knowledge of which storage backends exist:\n\nAll error-to-status mapping lives in one function. Domain errors go in, HTTP status codes\ncome out:\n\n- (1) ErrNotFound becomes 404. The handler doesn't know if it was a SQL miss, a Redis\n miss, or a soft delete. It doesn't need to.\n- (2) ErrConflict becomes 409. The handler doesn't know which constraint was violated.\n- (3) Anything else becomes 500 with a generic message. No internal details leak to the\n client.\n\nThe gRPC handler uses the same service with a different mapping function:\n\nwriteError and toStatus have the same shape. One outputs HTTP status codes, the other\noutputs gRPC status codes. The service behind both is identical. If you add a new error like\nErrForbidden, you define one sentinel in the user package and add one case to each\nmapping function.\n\nWhat you lose and how to get it back\n\nWhen the handler sees ErrNotFound, it doesn't know whether that was a SQL miss, a Redis\nmiss, or a soft delete. That's the whole point of the translation, but during an incident\nyou need that information.\n\nThis is why the repository and service wrap ErrNotFound with descriptive context using\n%w, as shown above. The repository produces \"user 42 not in db: not found\" and the\nservice produces \"user 42 soft-deleted: not found\". Same domain error, different origin.\nThe handler treats both as 404, but the error strings are distinct.\n\nTo make this useful, the handler logs the full error before returning the response:\n\nThe client sees a 404 with the body not found. The on-call engineer sees this:\n\nThe error string tells you which code path produced the error. If you have tracing set up,\nthe request-scoped context carries the trace ID too, so you can follow the 404 all the way\nback to the storage call that failed.\n\nThe standard library does the same thing\n\nThe os package translates platform-specific errors into portable ones. On Linux, opening a\nmissing file fails with syscall.ENOENT. On Windows, it fails with ERROR_FILE_NOT_FOUND.\nBut callers never see either:\n\nos.Open catches the platform error and wraps it so that errors.Is [maps it to\nfs.ErrNotExist]. Same idea as the repository catching sql.ErrNoRows and wrapping\nuser.ErrNotFound instead.\n\netcd's [clientv3 package] does the same translation in the reverse direction. The client\nreceives gRPC status codes from the server and maps them into plain Go errors so callers\nnever import google.golang.org/grpc/status. I covered this in [Wrapping a gRPC client in\nGo].\n\n---\n\nWorking examples for the [HTTP version] and the [gRPC version] are on GitHub, in the\n[error-translation] directory.\n\n\n\n\n[Go errors: to wrap or not to wrap?]:\n /go/to-wrap-or-not-to-wrap\n\n[Wrapping a gRPC client in Go]:\n /go/wrap-grpc-client\n\n[maps it to fs.ErrNotExist]:\n https://github.com/golang/go/blob/go1.24.2/src/syscall/syscall_unix.go#L120\n\n[clientv3 package]:\n https://github.com/etcd-io/etcd/blob/main/api/v3rpc/rpctypes/error.go#L185\n\n[error-translation]:\n https://github.com/rednafi/examples/tree/main/error-translation\n\n[HTTP version]:\n https://github.com/rednafi/examples/tree/main/error-translation/http\n\n[gRPC version]:\n https://github.com/rednafi/examples/tree/main/error-translation/grpc",
"title": "Error translation in Go services"
}