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.
Detailed reference material, loaded on demand:
references/clients.md—NewClient, service-config retries and load balancing, keepalive on both sides, reading status codes.references/testing.md— in-memorybufconnserver and client, asserting status codes.
Read a reference file only when the summary below is not enough.
1. Proto Design Rules
protobufsyntax = "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. - In CI,
buf lintandbuf breaking --against '.git#branch=main'stop renumbered fields and removed RPCs before they reach a client. Field rules (lengths, ranges, formats) belong in protovalidate annotations, enforced by an interceptor.
2. Errors: Status Codes, Not Strings
Return status.Error, mapping domain errors in ONE place:
gofunc (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") // no details leak } }
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
go// Client — every call gets a deadline ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() resp, err := client.CreatePayment(ctx, req) // Server — check before expensive work 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:
gosrv := grpc.NewServer( grpc.ChainUnaryInterceptor( recoveryInterceptor, // outermost: panic → codes.Internal 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.Sendin 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():
gofunc (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. Clients
grpc.NewClient(it replaces the deprecatedDial) once per target at startup; share the*grpc.ClientConn— it multiplexes and reconnects.- Retries, timeouts and load balancing in the service config: retry
UNAVAILABLEon idempotent methods only, and useround_robin(the defaultpick_firstsends everything to one backend). - Client keepalive pings must be allowed by the server's
EnforcementPolicy(defaultMinTime5 minutes), or the server closes the connection withtoo_many_pings. - Branch on
status.Code(err), never on the message.
Examples: references/clients.md.
7. Testing
Test through the generated client over bufconn: a real server with the
production interceptors, no network. Dial passthrough:///bufnet with
WithContextDialer — NewClient's default dns resolver would try to
resolve the name. Assert with status.Code(err). Example:
references/testing.md.
8. Production Server Setup
golis, err := net.Listen("tcp", cfg.Addr) if err != nil { return fmt.Errorf("listen: %w", err) } srv := grpc.NewServer( grpc.Creds(creds), // TLS; mTLS for service-to-service grpc.MaxRecvMsgSize(4<<20), // explicit; size it to the largest valid request grpc.ChainUnaryInterceptor(...), ) pb.RegisterPaymentServiceServer(srv, server) healthSrv := health.NewServer() // grpc.health.v1 — load balancers need it healthpb.RegisterHealthServer(srv, healthSrv) reflection.Register(srv) // grpcurl/debugging; gate on non-prod if policy requires go func() { <-ctx.Done() stopped := make(chan struct{}) go func() { srv.GracefulStop(); close(stopped) }() select { case <-stopped: // in-flight RPCs finished case <-time.After(10 * time.Second): srv.Stop() // force after grace period } }() return srv.Serve(lis)
For mTLS, the server's tls.Config sets ClientCAs and
ClientAuth: tls.RequireAndVerifyClientCert; handlers read the caller's
verified identity from peer.FromContext(ctx) rather than trusting
metadata.
Verification Checklist
- Proto packages versioned (
*.v1); no tag reuse;reservedfor removals - Dedicated Request/Response message per RPC
- Generated code produced by a pinned tool (buf/protoc) and committed;
buf breakingruns in CI - All handler errors are
status.Errorwith semantically correct codes codes.Internalresponses 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; TLS (mTLS internally) and an explicit receive limit
grpcurlsmoke test (or generated client test) passes against the running server- One shared
ClientConnper target, built withgrpc.NewClient; retries limited toUNAVAILABLE


