Go Error Semantics Across Service Boundaries: Typed Errors, Sentinel Values, and the gRPC Status Translation Problem

Go's error model is deliberately minimal: error is an interface with a single Error() string method. That minimalism is a feature inside a package, but it becomes an operational liability the moment an error crosses a network boundary. In a gRPC microservice graph, the gap between what your domain model knows about a failure and what the wire protocol can carry is where debugging hours disappear and retry storms start.

This article examines the mechanics of that translation problem, the failure modes that result from ignoring it, and a typed error propagation pattern that survives serialization without leaking internals.

Why Error Identity Breaks at the Wire

Inside a single Go process, errors have identity. You can use errors.Is and errors.As to walk an error chain and make precise branching decisions. The caller knows whether a failure is a transient network hiccup, a validation rejection, or a storage conflict because the types are in scope.

Across a gRPC call, you lose all of that. The transport carries a google.golang.org/grpc/status.Status, which is a code plus a string message plus optional google.protobuf.Any details. By default, if your server returns an unwrapped domain error, the framework converts it to codes.Unknown with the error string as the message. The caller receives a status it cannot branch on structurally. Every handler that cares about the difference between codes.NotFound and codes.FailedPrecondition must parse strings or accept a lossy abstraction.

The practical consequence: callers default to treating unknown gRPC errors as retryable or non-retryable based on the top-level code alone. A billing service that returns codes.Internal for both a database timeout and a constraint violation will cause its caller to retry the constraint violation indefinitely if the caller's retry policy keys on codes.Internal being transient. This is not hypothetical; it is the default outcome when teams wire gRPC without an explicit error translation layer.

Sentinel Values Are Not Contracts

The conventional Go approach for cross-package error identity is exported sentinel values:

var ErrNotFound = errors.New("not found") var ErrConflict = errors.New("conflict") 

This works inside a monorepo where packages share a dependency. It breaks across service boundaries for two reasons. First, sentinel values are pointer-equal within a process but cannot be serialized and recovered on the other side. Second, even within a monorepo, coupling a calling service to an internal package's sentinel values creates a compile-time dependency on the implementation package, which is the definition of interface pollution.

Typed errors using errors.As are more robust inside a process:

type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message) } 

But even typed errors cannot survive a gRPC round trip without explicit serialization support.

The gRPC Status Detail Mechanism

gRPC provides google.golang.org/grpc/status and the google.golang.org/genproto/googleapis/rpc/errdetails package for attaching structured detail to a status. The StatusFromProto / FromGRPCStatus pattern lets you embed a protobuf message inside the status details field, which survives serialization.

A production-grade error translation layer has three responsibilities:

  1. Domain → Status: At the server boundary, translate domain errors into gRPC statuses with attached proto details.
  2. Status → Domain: At the client boundary, extract proto details and reconstruct a typed domain error the caller can branch on without parsing strings.
  3. Observability: Emit the structured error at the translation point with enough context to reconstruct causality in traces.

Here is a minimal but complete implementation of this pattern:

// errors/domain.go — shared between server and client via an internal module package errors import ( "fmt" pb "yourorg/gen/errdetails" // generated from a proto definition ) type Kind int const ( KindNotFound Kind = iota KindConflict KindValidation KindUnavailable ) type DomainError struct { Kind Kind Code string // stable machine-readable code, e.g. "USER_NOT_FOUND" Message string // human-readable, never parsed by machines Meta map[string]string } func (e *DomainError) Error() string { return fmt.Sprintf("[%s] %s", e.Code, e.Message) } // ToProto converts a DomainError to the wire representation. func (e *DomainError) ToProto() *pb.DomainError { return &pb.DomainError{ Code: e.Code, Message: e.Message, Meta: e.Meta, } } // FromProto reconstructs a DomainError from the wire representation. func FromProto(p *pb.DomainError) *DomainError { return &DomainError{ Code: p.Code, Message: p.Message, Meta: p.Meta, } } 
// errors/grpc.go — server-side translation package errors import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/anypb" ) func ToGRPCStatus(err error) error { var de *DomainError if !errors.As(err, &de) { // Wrap unknown errors as Internal without leaking stack details return status.Error(codes.Internal, "internal error") } code := domainKindToGRPCCode(de.Kind) st, _ := status.New(code, de.Message).WithDetails( mustAny(de.ToProto()), ) return st.Err() } func domainKindToGRPCCode(k Kind) codes.Code { switch k { case KindNotFound: return codes.NotFound case KindConflict: return codes.AlreadyExists case KindValidation: return codes.InvalidArgument case KindUnavailable: return codes.Unavailable default: return codes.Internal } } 
// errors/grpc_client.go — client-side extraction package errors import ( pb "yourorg/gen/errdetails" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" ) func FromGRPCError(err error) error { st, ok := status.FromError(err) if !ok { return err } for _, detail := range st.Details() { if p, ok := detail.(*pb.DomainError); ok { return FromProto(p) } } // No structured detail: fall back to a DomainError keyed on the gRPC code return &DomainError{ Code: st.Code().String(), Message: st.Message(), } } 

With this pattern, a caller can do:

err := FromGRPCError(rpcErr) var de *DomainError if errors.As(err, &de) && de.Code == "USER_NOT_FOUND" { return handleMissingUser(ctx) } 

No string parsing. No implicit coupling to gRPC codes. The Code field is a stable contract defined in the proto schema, versioned with the API.

The Observability Seam

The translation functions are the right place to emit structured telemetry. Every call to ToGRPCStatus that produces a non-Internal code is a client error; every Internal is a server fault. Logging this distinction at the boundary — not deep inside business logic — gives you accurate error-rate signals without polluting domain code with observability concerns:

func ToGRPCStatus(err error, logger *slog.Logger, span trace.Span) error { grpcErr := toGRPCStatus(err) // internal implementation st, _ := status.FromError(grpcErr) span.SetAttributes(attribute.String("rpc.error_code", st.Code().String())) if st.Code() == codes.Internal { logger.Error("internal server error", "err", err) } return grpcErr } 

This keeps the signal at the boundary where it has the most operational meaning.

Package Boundary Discipline

The pattern above depends on a shared errors package that both server and client import. In a microservice architecture this means either a shared internal module (valid if services are in one repository) or a versioned SDK package. The critical constraint: this package must contain only wire types and translation logic. It must not import any service-specific packages. Violating that constraint collapses the boundary and reintroduces the coupling problem.

If services live in separate repositories, the proto-defined DomainError message becomes the sole cross-service contract. Each service owns its translation to and from that wire format independently. The proto schema is the API; the Code field values are documented and change-controlled like any other API field.

Decision Framework

Apply this translation pattern when all of the following are true:

  • The service exposes a gRPC API consumed by more than one caller.
  • Callers need to make branching decisions based on failure type, not just success/failure.
  • The service has more than one logical error category that maps differently for retry policy, alerting, or user-facing messaging.

Skip it when the service is an internal implementation detail with a single caller under your control, or when failures are uniformly fatal from the caller's perspective. In those cases, a simple codes.Internal with a logged error is less complexity for equivalent operational value.

The measure of a correct error design at a service boundary is not elegance — it is whether an on-call engineer can determine from a single trace span whether a failure was the client's fault or the server's, and whether the retry that followed was correct.