Error Wrapping Across Package Boundaries in Go Projects
How errors should change as they cross repository, service and handler layers in Go: translate at the boundary, wrap with context inside, log once at the top.

A small program has one place where errors are handled. A service has three or four: storage, business logic, transport, and often a client library beneath them. Each layer sees the error differently, and the rules that work for a single function, wrap and return, need a little more thought once the error crosses a package boundary. The hard part is not mechanics. It is deciding what each layer is allowed to know, because an error is a message to the layer above, and what it says becomes that layer's dependency.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article follows one failure up through three layers and shows what each should do. It builds on wrapping with %w and logging errors once, is part of the error handling guide, and leads into domain errors. Everything ran on Go 1.25.6.
The shape of a service
Take a service that reports the total of an order. The call goes handler, then service, then repository, then the database:
- Repository: knows SQL. It speaks
database/sql, and its errors mention drivers, tables and constraints. - Service: knows the business. It speaks orders and invoices and should not know that SQL exists.
- Handler: knows HTTP. It speaks status codes and response bodies.
An error born in the database passes through all three. Each layer has a different relationship to it, and getting those relationships right is what keeps the layers independent.
What each layer does
| Layer | Receives | Does | Returns |
|---|---|---|---|
| Repository | Driver errors | Translates the ones callers must branch on; wraps the rest with the operation | Domain errors, or wrapped unexpected ones |
| Service | Domain errors | Wraps with %w and a business phrase | The same chain, longer |
| Handler | A chain | Maps it to a status, logs once | A response |
Two verbs appear, and the difference is the heart of this article. Wrapping keeps the cause: the original stays in the chain and callers can reach it. Translating replaces it: the layer returns a different error and the caller never sees the original type. Use wrapping when the cause is part of what the next layer needs to know. Use translation when the cause is an implementation detail that should not become anybody's dependency.
The code
Here is the whole path in one file, so the chain is easy to follow:
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
)
// Domain vocabulary, owned by the service.
var ErrNotFound = errors.New("not found")
type Order struct{ ID, Cents int }
type OrderStore interface {
Get(ctx context.Context, id int) (Order, error)
}
// --- repository layer ---
type sqlStore struct{}
func (sqlStore) Get(_ context.Context, id int) (Order, error) {
var err error
switch id {
case 1:
return Order{ID: 1, Cents: 2500}, nil
case 7:
err = sql.ErrNoRows
default:
err = errors.New("pq: connection refused")
}
if errors.Is(err, sql.ErrNoRows) {
return Order{}, fmt.Errorf("order %d: %w", id, ErrNotFound)
}
return Order{}, fmt.Errorf("query order %d: %w", id, err)
}
// --- service layer ---
type Service struct{ store OrderStore }
func (s Service) Total(ctx context.Context, id int) (int, error) {
o, err := s.store.Get(ctx, id)
if err != nil {
return 0, fmt.Errorf("compute invoice total: %w", err)
}
return o.Cents, nil
}
// --- handler layer ---
var log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}))
func handle(svc Service, id int) {
total, err := svc.Total(context.Background(), id)
switch {
case err == nil:
fmt.Printf("order %d: 200 total=%d\n", id, total)
case errors.Is(err, ErrNotFound):
fmt.Printf("order %d: 404\n", id)
default:
log.Error("request failed", "err", err, "order_id", id)
fmt.Printf("order %d: 500\n", id)
}
}
func main() {
svc := Service{store: sqlStore{}}
for _, id := range []int{1, 7, 9} {
handle(svc, id)
}
}order 1: 200 total=2500
order 7: 404
level=ERROR msg="request failed" err="compute invoice total: query order 9: pq: connection refused" order_id=9
order 9: 500Follow order 7, the not-found case. The database says sql.ErrNoRows, a fact about database/sql. The repository recognises it and returns ErrNotFound, the domain's word, wrapped with the order number. It does not wrap sql.ErrNoRows into the chain. The service adds compute invoice total: and passes it up. The handler asks errors.Is(err, ErrNotFound) and answers 404 without ever importing database/sql. Replace the database with a different one tomorrow and the service and handler do not change.
Order 9 is the unexpected case: the repository has no translation for a connection failure, so it wraps the raw error with the operation, query order 9: pq: connection refused, the service adds its phrase, and the handler logs the whole chain once and answers 500. The chain reads from outermost action to root cause: compute invoice total: query order 9: pq: connection refused. That is the message an engineer needs.
Why translate at the boundary
Suppose the repository had wrapped sql.ErrNoRows with %w instead of translating. Everything would work today. But now the handler could write errors.Is(err, sql.ErrNoRows), and some day someone will, because it works. From then on the handler depends on the storage technology. Moving to a service that returns a different "no rows" error breaks a layer that had nothing to do with storage, and the break surfaces as a wrong status code in production, not as a compile error.
The rule that follows: errors that a caller may branch on belong to the layer's own vocabulary. If the repository's callers need to know "the order does not exist", the repository defines or reuses a name for that and returns it. If they do not need to branch, the raw driver error can be wrapped and passed up for logging, where its text helps and its type does not matter, since nobody is expected to test for it.
When you want the driver's text for debugging but not its type in the chain, use %v deliberately:
package main
import (
"database/sql"
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func main() {
driver := sql.ErrNoRows
kept := fmt.Errorf("order 7: %w", driver)
cut := fmt.Errorf("order 7: %w (driver said: %v)", ErrNotFound, driver)
fmt.Println(errors.Is(kept, sql.ErrNoRows), errors.Is(kept, ErrNotFound))
fmt.Println(errors.Is(cut, sql.ErrNoRows), errors.Is(cut, ErrNotFound))
fmt.Println(cut)
}true false
false true
order 7: not found (driver said: sql: no rows in result set)The second error is a domain error whose message mentions the driver for the human reader, while errors.Is finds only the domain sentinel. This is the best of both: the log has the detail and the types stay clean.
Layer rules in practice
A few habits keep the layers honest.
Add context where the reader lacks it. The repository knows which table and which key. The service knows which business operation. The handler knows the request. Each adds what only it knows, as in query order 9 and compute invoice total. A layer that adds nothing returns the error unchanged (adding context without noise).
Do not name your own layer. A phrase such as repository: or service: tells the reader which package the message came from, which is the least interesting fact about it. Say what was being attempted.
Log in one place. Of the three layers only the handler logs, and only for the unexpected case. The 404 is a normal outcome and logs nothing here. Lower layers that also logged would produce three lines for order 9 (log errors once).
Keep the vocabulary in the domain package. If ErrNotFound is declared in the repository package, the service imports the repository to name it, and the dependency points the wrong way: business logic now imports storage. Declare domain errors in the package that defines the domain types, and let the repository import that package. Domain errors goes into where they live.
Cross-package wrapping is fine inside a team. %w between your own packages is usually safe, since you control both sides. The strict translation matters at the seams that will change: the database, third-party clients, anything behind an interface you may swap.
Test each layer against a fake below it
The interface between layers is a seam for testing. A fake store returns whichever error the test needs, and the layer above is checked for its reaction to the domain error, not the driver's:
package main
import (
"context"
"errors"
"testing"
)
type fakeStore struct{ err error }
func (f fakeStore) Get(context.Context, int) (Order, error) { return Order{}, f.err }
func TestServiceKeepsDomainError(t *testing.T) {
svc := Service{store: fakeStore{err: ErrNotFound}}
_, err := svc.Total(context.Background(), 1)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("service lost the domain error: %v", err)
}
}
func TestRepoTranslatesNoRows(t *testing.T) {
_, err := sqlStore{}.Get(context.Background(), 7)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}
}=== RUN TestServiceKeepsDomainError
--- PASS: TestServiceKeepsDomainError (0.00s)
=== RUN TestRepoTranslatesNoRows
--- PASS: TestRepoTranslatesNoRows (0.00s)
PASS
ok example.test/lab 1.267sThe first test proves the service wraps without losing the sentinel, and the second proves the repository speaks the domain's language for a missing row. If a refactor makes the service use %v by mistake, the first fails. If someone deletes the translation, the second fails. Neither test needs a database.
The client layer: translating someone else's failures
A fourth layer often sits beside the repository: a client for another service. Its errors have the same problem in a different accent. The HTTP client returns *url.Error for transport failures and a status code for application ones, and nothing above should have to know either. The client translates once, at its edge:
package main
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
)
var (
ErrNotFound = errors.New("not found")
ErrUnavailable = errors.New("upstream unavailable")
)
type BillingClient struct{ base string }
func (c BillingClient) Invoice(id int) (string, error) {
resp, err := http.Get(fmt.Sprintf("%s/invoices/%d", c.base, id))
if err != nil {
return "", fmt.Errorf("billing: get invoice %d: %w (%v)", id, ErrUnavailable, err)
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound:
return "", fmt.Errorf("billing: invoice %d: %w", id, ErrNotFound)
case resp.StatusCode >= 500:
return "", fmt.Errorf("billing: invoice %d: status %d: %w", id, resp.StatusCode, ErrUnavailable)
}
return "paid", nil
}
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/invoices/1":
fmt.Fprint(w, "ok")
case "/invoices/2":
http.NotFound(w, r)
default:
http.Error(w, "boom", http.StatusBadGateway)
}
}))
defer srv.Close()
c := BillingClient{srv.URL}
for _, id := range []int{1, 2, 3} {
v, err := c.Invoice(id)
fmt.Println(id, v, errors.Is(err, ErrNotFound), errors.Is(err, ErrUnavailable))
}
dead := BillingClient{"http://127.0.0.1:1"}
_, err := dead.Invoice(4)
fmt.Println(errors.Is(err, ErrUnavailable))
}1 paid false false
2 true false
3 false true
trueThe service above this client branches on two words, not found and unavailable, whichever way the failure arrived: a 404, a 502, or a refused connection that never produced a status. The driver-level detail (connection refused) survives as text with %v for the logs. A retry layer can now treat ErrUnavailable as worth another attempt without knowing about HTTP (retryable and permanent errors).
Transactions and cleanup across a layer
Repositories that run transactions have a second error to carry: the rollback. The usual shape is that the work fails, the rollback is attempted, and the caller needs the work's error first. If the rollback also fails, join it rather than hiding it, since a failed rollback means the connection is in an unknown state:
if err := work(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return errors.Join(err, fmt.Errorf("rollback: %w", rbErr))
}
return err
}
return tx.Commit()The chain then holds both, and errors.Is finds a domain sentinel inside the work error as before. The point for layering is that none of this escapes the repository: the service sees one error from Transfer, wrapped with the operation, and neither knows nor cares that a transaction existed (errors.Join has the details of combining).
Where the files go
The layering is easiest to keep when the package layout mirrors it. A layout that works for a service of modest size puts the domain types and their errors in one package that imports nothing from the others, the repository and client packages import the domain, the service imports the domain and defines the interfaces it needs, and the transport package imports the service. The Go team's guidance on organizing a module describes the internal directory, which is the right home for these packages: it stops other modules from importing your storage code and then depending on its errors. Dependencies point inward, toward the domain, and so do the errors: the outer layers translate into the domain's words, and the inner layers never import an outer one.
Adopting this in an existing codebase
Nobody gets to start clean. If your service already leaks driver errors upward, do not rewrite everything at once. Pick the one decision that hurts most, usually the "not found" check, and fix that: define the domain error, translate it in the repository, and change the callers that matched on sql.ErrNoRows to match on the new name. A grep for sql.ErrNoRows and for err.Error() comparisons outside the repository finds the leaks. Add the fake-store test above for that one path, and the next conversion is easier because the pattern is in the codebase. The reference for the error the example translates is the sql.ErrNoRows documentation, which says it is returned by Row.Scan when there is no row, exactly the case a repository should absorb.
Cancellation passes through untouched
One kind of error should cross every layer without translation: the ones from context. When a request is cancelled or its deadline passes, the database driver, the HTTP client and your own loops all return context.Canceled or context.DeadlineExceeded, often wrapped in their own types. A layer that converts these into its own ErrUnavailable or a generic failure erases the one fact the handler needs to answer correctly: that the caller left, or that the time budget ran out. The rule is to wrap them with %w like any other error and let the handler test with errors.Is, as the status mapping in mapping domain errors to HTTP statuses does. Translating a cancelled query into "database error" turns every client disconnect into a page for the on-call engineer.
The same applies to retries. A retry loop in the service layer must check whether the context is done before it tries again, and return the context's error, not the last failure of the operation it was retrying, when the budget is gone. Otherwise a cancelled request keeps hammering a struggling dependency until the retries run out, and the logs blame the dependency for what was the caller's decision.
A short review checklist
When you read a pull request that touches errors across layers, a handful of questions find most of the problems.
- Does any package above the repository import
database/sqlor a driver, only to name an error? - Does a lower layer log an error it also returns?
- Is there a
%vwhere a%wwas meant, or the other way around, and is there a comment where it was chosen on purpose? - Do the context errors survive every layer?
- Is the domain error declared in the package that owns the domain, and in only one place?
- Is there a test with a fake that returns the domain error and checks the layer above reacts to it?
A change that answers all six well is usually correct, whatever else it does. A change that cannot answer one of them is worth a conversation before it merges, since layering mistakes are cheap on the day they are made and expensive a year later.
Failure modes to watch for
Three problems recur. A sentinel declared in two places, so errors.Is fails across layers; keep one definition. Over-wrapping, where each of seven layers adds a phrase and the message becomes a paragraph; wrap at boundaries and where information is added. Translating too eagerly, converting every driver error into a vague ErrInternal and throwing away the text an on-call engineer needs. Keep the original message in the chain for the unexpected cases, as order 9 does.
For the mechanics the layers rely on, read wrapping with %w, errors Is and As, and the Go blog's Working with Errors in Go 1.13, whose closing section makes the same distinction between wrapping and opaque errors.


