A new Go service rarely has structural problems. The problems show up about a year later, when five people have contributed, the utils package has 40 functions, and a log line reads error: not found with no clue about what wasn't found. Two habits prevent most of that decay: disciplined error handling and a package layout based on purpose rather than file type.
Errors are values, so give them context
In Go, an error is an ordinary value returned alongside a result. That makes errors explicit, but it also makes it tempting to pass them upward unchanged:
if err != nil {
return err // where did this come from?
}By the time that error reaches the HTTP handler, it has lost every piece of information about the path it took. Wrap it at each layer instead, using %w so the original stays inspectable:
func (s *OrderService) Cancel(ctx context.Context, id string) error {
order, err := s.repo.Get(ctx, id)
if err != nil {
return fmt.Errorf("cancel order %s: load: %w", id, err)
}
if err := order.Cancel(); err != nil {
return fmt.Errorf("cancel order %s: %w", id, err)
}
return s.repo.Save(ctx, order)
}The final message reads like a breadcrumb trail, for example cancel order 8812: load: sql: no rows in result set, and you get it without a stack trace.
A wrapping rule of thumb: add context describing what this function was trying to do, not what failed. The inner error already explains what failed.
Three kinds of errors and when to use each
1. Sentinel errors for expected conditions
When callers need to branch on a specific, well-known outcome, export a package-level variable:
var ErrNotFound = errors.New("order not found")
// caller
if errors.Is(err, orders.ErrNotFound) {
return http.StatusNotFound
}errors.Is walks the wrap chain, so the check still works after several layers of fmt.Errorf("...: %w", err).
2. Typed errors when callers need data
If the caller needs details, such as which field failed validation, define a type:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}
// caller
var ve *orders.ValidationError
if errors.As(err, &ve) {
respondBadRequest(w, ve.Field, ve.Reason)
}3. Opaque errors for everything else
Most errors should stay opaque. The caller only needs to know something failed, and the message is for humans reading logs. Don't export sentinels for conditions nobody branches on, because every exported error becomes part of your API contract.
Translate errors at the boundary
Domain code shouldn't know about HTTP status codes, and handlers shouldn't need to know about sql.ErrNoRows. Translate once, at each edge:
- Repository layer: turn driver-specific errors (
sql.ErrNoRows, unique-constraint violations) into domain errors (ErrNotFound,ErrDuplicate). - Transport layer: map domain errors to status codes in one place.
func statusFor(err error) int {
switch {
case errors.Is(err, domain.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, domain.ErrConflict):
return http.StatusConflict
default:
var ve *domain.ValidationError
if errors.As(err, &ve) {
return http.StatusBadRequest
}
return http.StatusInternalServerError
}
}Keeping this mapping in one function makes it easy to audit and hard to get inconsistent.
Log once, at the top
A common anti-pattern is logging an error and then returning it too. The same failure then appears four times in your logs, at every level of the call stack. Choose one: either handle the error (log, retry, or fall back) or return it with context, never both. In most services the logging happens once, in the request middleware, together with the request ID.
Don't panic across package boundaries
panic is for truly unrecoverable programmer errors, such as a nil map that should have been initialized at startup. It isn't for validation failures or network timeouts. If a library you depend on can panic, recover at the goroutine boundary and convert the panic into an error, so one bad request doesn't bring down the process.
Package layout: organize by what the code does
The most common layout mistake is grouping code by technical category:
/models
/controllers
/services
/utilsWith this layout, one feature change touches four directories, and utils grows without limit. Group by domain capability instead:
/cmd/api/main.go # wiring only: config, dependencies, server start
/internal/order/ # order domain: types, service, repository interface
/internal/order/postgres/ # repository implementation
/internal/payment/
/internal/platform/httpx/ # shared HTTP helpers with a real, narrow purpose
/internal/platform/dbx/Principles behind this layout:
internal/stops other modules from importing your packages, so you can refactor freely.cmd/holds thin entrypoints.main.gobuilds dependencies and callsRun. It holds no business logic.- Interfaces live where they're used. The
orderpackage defines theRepositoryinterface it needs, andorder/postgresimplements it. This keeps the domain package free of database imports and makes it easy to fake in tests. - Package names are nouns that describe what the package provides, such as
order,payment, orratelimit. Avoidcommon,helpers, andmisc, because a name that describes nothing will end up holding everything.
Keep main boring
Explicit wiring in main is a strength, not boilerplate to hide behind a framework:
func run(ctx context.Context, cfg Config) error {
db, err := dbx.Open(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
orders := order.NewService(orderpg.NewRepo(db))
router := httpapi.NewRouter(orders)
return httpx.Serve(ctx, cfg.Addr, router)
}Anyone can read this function and see the entire dependency graph. Returning an error from run rather than calling log.Fatal everywhere also lets deferred cleanup actually run.
Summary
- Wrap errors with
%wand describe the operation being attempted. - Export sentinels and typed errors only for conditions callers truly branch on.
- Translate errors at the storage and transport boundaries.
- Log each error once, at the top.
- Lay out packages by domain, keep them
internal, and keepmainexplicit.
None of these conventions is complicated. Together they are what keep a Go codebase pleasant to work in after a year of steady feature work.
