Lesson 12 · Errors & idiomatic Go

gRPC status codes

Turning a Go error into a response the client can branch on — at the right layer.

Your win: map a domain error to a codes.* status the way our services do, and explain why that mapping lives only at the controller boundary — not deep in the usecase.

gRPC speaks in status codes

A gRPC handler doesn't return a bare Go error to the client — it returns a status: a codes.Code plus a message, built with status.Error(code, msg).1 The code is what lets the caller branch (retry? show a 400? deny?), so choosing the right one matters.

Map at the boundary, nowhere else

The layering rule Domain and usecase code returns plain (wrapped) Go errors and stays transport-agnostic. Only the controller — which knows it's serving gRPC — translates errors into codes.*. That keeps business logic reusable from a job, a NATS handler, or another transport.

Our SendEmail handler is the template: it uses errors.Is (Lesson 11) on the sentinels the validator returned, and picks a code per case:

internal/spike/modules/email/controller/grpc/email_modifier_send_email.go:20-38
if err := svc.validateSendEmailPayloads(ctx, ...); err != nil {
    if errors.Is(err, ErrMediasSizeExceed) || errors.Is(err, ErrEmailsCountExceed) {
        return nil, status.Error(codes.ResourceExhausted, err.Error())
    }
    if errors.Is(err, ErrInternal) {
        return nil, status.Error(codes.Internal, err.Error())
    }
    return nil, status.Error(codes.InvalidArgument, err.Error())
}

The repo's code map (from .claude/rules/security.md)

SituationCode
Bad/invalid inputcodes.InvalidArgument
Infra / unexpected failurecodes.Internal
Size / rate limit exceededcodes.ResourceExhausted
Entity missingcodes.NotFound
Any auth failurecodes.PermissionDeniednever Internal
Anchor — the shared PG translator Repos don't hand-map every DB error. errorx.ToStatusError (internal/golibs/errorx/errorx.go:56-78) uses errors.As to read a *pgconn.PgError and map it: UniqueViolationcodes.AlreadyExists (with PreconditionFailure details), ForeignKeyViolationcodes.InvalidArgument. One place, reused everywhere.
Security: don't over-share in the message The code is for branching; the message is visible to clients. Don't leak internal details or stack traces, and never dress an auth failure up as Internal (it must be PermissionDenied). This is both an idiom and a security rule in our repo.
Read this next

gRPC codes package + status semantics

The canonical list of codes and exactly what each means — so your mapping matches what clients expect across languages.

pkg.go.dev/google.golang.org/grpc/codes
grpc/status (building & reading status)

Check yourself (from memory)

Q1. In our services, errors are mapped to gRPC status codes…

The controller knows it's gRPC; deeper layers stay transport-agnostic and just return errors. Keeps logic reusable.

Q2. A validation failure should map to…

Bad input → InvalidArgument. Size/rate limits → ResourceExhausted; infra → Internal.

Q3. An authentication/authorization failure must map to…

Our security rule: auth failures are always PermissionDenied — never disguised as Internal.
Why map errors to gRPC codes at the controller, not deep in the usecase?
recall, then click to reveal
The domain/usecase layer stays transport-agnostic — it returns plain (wrapped) errors, so it's reusable from a job, NATS, or another transport. Only the controller knows it's serving gRPC, so it translates errors → codes.* there (via errors.Is/As). Centralises the mapping and keeps business logic clean. Bonus: never leak internals in the message, and auth failures → PermissionDenied.
Want the full canonical code list with when-to-use notes, or how status details (structured error payloads) work? Ask me.

1. gRPC codes package; gRPC status package.