| name | go-grpc |
| description | gRPC services in Go beyond the basics: proto design, status codes and error details, interceptors, deadlines, streaming, health checks, and graceful shutdown. Use when: "gRPC service", "proto design", "gRPC error handling", "interceptor", "gRPC streaming", "gRPC deadline", "grpc health check", "gRPC status codes". Do NOT use for: REST/HTTP handler design (use go-api-design), protobuf-agnostic API layering (use go-architecture-review), or TLS hardening details (use go-security-audit).
|
| license | MIT |
| metadata | {"version":"1.0.0"} |
Go gRPC Services
gRPC's contract-first model only pays off if the contract is treated as
an API: versioned packages, deliberate error codes, deadlines
everywhere, and interceptors for everything cross-cutting.
1. Proto Design Rules
syntax = "proto3";
package payment.v1; // version IN the package
option go_package = "github.com/acme/payment-service/gen/payment/v1;paymentv1";
service PaymentService {
rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse);
}
message CreatePaymentRequest { // one request/response pair
string order_id = 1; // per RPC, always — even if
int64 amount_cents = 2; // empty today
}
message CreatePaymentResponse {
Payment payment = 1;
}
- Version in the package (
payment.v1); breaking change = payment.v2.
- Never reuse or renumber field tags;
reserved 3, 7; deleted ones.
- Dedicated Request/Response messages per RPC — adding a field later is
free; changing a shared message breaks every RPC using it.
- Generate with buf or a pinned protoc in
make generate; commit
generated code so builds don't depend on toolchain drift.
2. Errors: Status Codes, Not Strings
Return status.Error, mapping domain errors in ONE place:
func (s *Server) CreatePayment(ctx context.Context, req *pb.CreatePaymentRequest) (*pb.CreatePaymentResponse, error) {
p, err := s.svc.Create(ctx, toDomain(req))
if err != nil {
return nil, toStatus(err)
}
return &pb.CreatePaymentResponse{Payment: fromDomain(p)}, nil
}
func toStatus(err error) error {
switch {
case errors.Is(err, domain.ErrNotFound):
return status.Error(codes.NotFound, "payment not found")
case errors.Is(err, domain.ErrDuplicate):
return status.Error(codes.AlreadyExists, "payment already exists")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "timed out")
default:
return status.Error(codes.Internal, "internal error")
}
}
Code semantics that matter: InvalidArgument (bad request regardless
of state), FailedPrecondition (bad state), NotFound, AlreadyExists,
Unauthenticated vs PermissionDenied, Unavailable (retryable),
Internal (bug). Clients read codes with status.FromError(err) —
never parse messages.
3. Deadlines Are Mandatory
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := client.CreatePayment(ctx, req)
if err := ctx.Err(); err != nil {
return nil, status.FromContextError(err).Err()
}
The server inherits the client's deadline through the context. Pass
ctx into every downstream call (DB, other RPCs) so cancellation
propagates end to end.
4. Interceptors for Cross-Cutting Concerns
Handlers stay business-only; recovery, auth, logging, metrics live in
interceptors:
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
recoveryInterceptor,
loggingInterceptor,
authInterceptor,
),
)
func loggingInterceptor(ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
slog.InfoContext(ctx, "rpc",
slog.String("method", info.FullMethod),
slog.Duration("duration", time.Since(start)),
slog.String("code", status.Code(err).String()),
)
return resp, err
}
Order matters: recovery first (outermost), then observability, then
auth. Streaming RPCs need the parallel StreamInterceptor versions.
5. Streaming
- Server streaming for large result sets:
stream.Send in a loop,
return non-nil error to abort with a status.
- Client/bidi streaming only when the protocol truly needs it —
each open stream holds a goroutine and flow-control state.
- Always terminate on
ctx.Done():
func (s *Server) WatchPayments(req *pb.WatchRequest, stream pb.PaymentService_WatchPaymentsServer) error {
for {
select {
case <-stream.Context().Done():
return status.FromContextError(stream.Context().Err()).Err()
case ev := <-s.events:
if err := stream.Send(toProto(ev)); err != nil {
return err
}
}
}
}
6. Production Server Setup
lis, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return fmt.Errorf("listen: %w", err)
}
srv := grpc.NewServer(grpc.ChainUnaryInterceptor(...))
pb.RegisterPaymentServiceServer(srv, server)
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(srv, healthSrv)
reflection.Register(srv)
go func() {
<-ctx.Done()
stopped := make(chan struct{})
go func() { srv.GracefulStop(); close(stopped) }()
select {
case <-stopped:
case <-time.After(10 * time.Second):
srv.Stop()
}
}()
return srv.Serve(lis)
Verification Checklist
- Proto packages versioned (
*.v1); no tag reuse; reserved for removals
- Dedicated Request/Response message per RPC
- Generated code produced by a pinned tool (buf/protoc) and committed
- All handler errors are
status.Error with semantically correct codes
codes.Internal responses never leak internal error text
- Every client call has a deadline; ctx propagated through all layers
- Recovery, logging, auth implemented as chained interceptors (unary + stream)
- Streams select on
stream.Context().Done()
- Health service registered; graceful stop with forced fallback
grpcurl smoke test (or generated client test) passes against the running server