Go gRPC
Treat gRPC as a transport. Keep .proto-generated code and business logic separated. The official Go implementation is google.golang.org/grpc; pair it with protoc-gen-go + protoc-gen-go-grpc (or buf generate).
Core Rules
- One concern per layer.
.proto defines the contract; generated code lives in gen/; service implementation lives in internal/. Never edit generated files.
- Always wrap RPC arguments in Request/Response messages. Bare scalars (
string, int32) cannot be evolved without breaking callers.
- Return typed status codes, never raw errors. A
fmt.Errorf becomes codes.Unknown on the wire — the client cannot decide whether to retry.
- Every client call has a deadline. No
context.Background() to a remote service. Set context.WithTimeout per call.
- Reuse connections. HTTP/2 multiplexes; creating a new
grpc.ClientConn per request is a TLS handshake leak.
- Disable reflection in production. Reflection is a developer convenience that doubles as an API enumeration tool for attackers.
When to Use What
| Need |
Use |
| Define service |
.proto file in proto/<service>/v1/ |
| Generate stubs |
buf generate or protoc --go_out --go-grpc_out |
| Cross-cutting (auth, logging, recovery) |
grpc.ChainUnaryInterceptor / ChainStreamInterceptor |
| Health probes (Kubernetes) |
grpc_health_v1 from google.golang.org/grpc/health |
| Errors with details |
status.Errorf(codes.X, ...) + WithDetails(errdetails.BadRequest{...}) |
| Tests |
google.golang.org/grpc/test/bufconn |
| Service mesh / mTLS |
credentials.NewTLS or delegate to Istio/Linkerd |
Read references/proto-and-codegen.md when organizing .proto packages or wiring buf.
Read references/status-and-errors.md when mapping domain errors to gRPC codes.
Server Bootstrap
import (
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(recoveryUnary, loggingUnary, authUnary),
grpc.ChainStreamInterceptor(recoveryStream, loggingStream),
)
pb.RegisterUserServiceServer(srv, &userService{...})
healthpb.RegisterHealthServer(srv, health.NewServer())
go func() { _ = srv.Serve(lis) }()
// Graceful shutdown bounded by a hard timeout.
<-shutdownSignal
stopped := make(chan struct{})
go func() { srv.GracefulStop(); close(stopped) }()
select {
case <-stopped:
case <-time.After(15 * time.Second):
srv.Stop()
}
Three pieces are non-negotiable: interceptors for cross-cutting concerns, health service for Kubernetes probes, and a bounded graceful shutdown.
Client Bootstrap
conn, _ := grpc.NewClient("dns:///user-service:50051",
grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)),
grpc.WithDefaultServiceConfig(`{
"loadBalancingPolicy": "round_robin",
"methodConfig": [{
"name": [{"service": "user.v1.UserService"}],
"timeout": "5s",
"retryPolicy": {
"maxAttempts": 3, "initialBackoff": "0.1s", "maxBackoff": "1s",
"backoffMultiplier": 2, "retryableStatusCodes": ["UNAVAILABLE"]
}
}]
}`),
)
client := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second); defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: id})
The service config is the right place for retries — let the library handle the loop, backoff, and UNAVAILABLE-only filter.
Errors
A raw Go error returned from an RPC becomes codes.Unknown. The client cannot tell a 404 from a 500. Always use status.Errorf:
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "user %q not found", req.Id)
}
if errors.As(err, &validationErr) {
st, _ := status.New(codes.InvalidArgument, "validation").WithDetails(
&errdetails.BadRequest{FieldViolations: violations(validationErr)},
)
return nil, st.Err()
}
return nil, status.Errorf(codes.Internal, "lookup: %v", err)
Quick map:
| Domain |
Code |
| Missing/invalid field |
InvalidArgument |
| Not found |
NotFound |
| Already exists |
AlreadyExists |
| Unauthenticated |
Unauthenticated |
| Authenticated but forbidden |
PermissionDenied |
| Rate-limited |
ResourceExhausted |
| Dependency down, retriable |
Unavailable |
| Bug, unexpected |
Internal |
Streaming
| Pattern |
Use case |
| Server streaming |
Log tailing, paginated result sets, server-sent events |
| Client streaming |
File upload, batch ingest |
| Bidirectional |
Chat, real-time sync |
Streams must respect ctx.Done(). A goroutine reading from a stream after the client disconnects is a slow leak.
func (s *server) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
for _, u := range s.repo.All(stream.Context()) {
if err := stream.Send(toProto(u)); err != nil {
return err // includes ctx canceled
}
}
return nil
}
Testing with bufconn
bufconn is an in-memory net.Listener. It exercises the real gRPC stack — interceptors, marshaling, metadata — without binding a TCP port. See references/testing.md for the full harness plus table-driven status-code assertions, metadata injection, and stream testing.
Security Notes
- TLS in production. Plaintext is only acceptable behind a confirmed-private network (and even then mTLS is preferable).
- For service-to-service auth, prefer a mesh (Istio/Linkerd) over hand-rolled token validation.
- For user auth, implement
credentials.PerRPCCredentials to attach a token and validate inside an auth interceptor.
- Reflection: enable in dev, disable in prod via build tag or env flag.
Anti-Patterns
| Anti-pattern |
Why it hurts |
Do this instead |
return fmt.Errorf("not found") |
Wire code is Unknown, clients can't retry-discriminate |
status.Errorf(codes.NotFound, ...) |
context.Background() to a client call |
No deadline → goroutines pile up on a slow dependency |
context.WithTimeout(parent, 5s) |
New ClientConn per request |
TLS handshake every call; sockets exhaust |
One grpc.NewClient at startup, reuse |
Bare string as RPC argument |
Cannot add fields without breaking callers |
Always Request/Response messages |
| Reflection on in production |
Lets attackers enumerate every method |
Compile-out with build tag in prod |
codes.Internal for all errors |
Client retry config can't distinguish bugs from outages |
Map domain → specific codes |
| No health service |
Kubernetes can't gate traffic; rolling deploys break |
Register grpc_health_v1 |
Ignoring stream.Context().Done() |
Goroutines run after client disconnect |
Select on ctx.Done() in stream loops |
Verification Checklist
References
1---2name: go-grpc-23description: Use when implementing or reviewing gRPC servers/clients in Go. Covers .proto organisation, code generation with protoc/buf, server bootstrap (interceptors, health, graceful shutdown), client patterns (reuse, deadlines, retries), status.Code error handling, streaming, TLS/mTLS, and bufconn testing. Apply when writing .proto files, adding interceptors, or auditing a service for production readiness.4license: MIT5---67# Go gRPC89Treat gRPC as a transport. Keep `.proto`-generated code and business logic separated. The official Go implementation is `google.golang.org/grpc`; pair it with `protoc-gen-go` + `protoc-gen-go-grpc` (or `buf generate`).1011## Core Rules12131. **One concern per layer.** `.proto` defines the contract; generated code lives in `gen/`; service implementation lives in `internal/`. Never edit generated files.142. **Always wrap RPC arguments in Request/Response messages.** Bare scalars (`string`, `int32`) cannot be evolved without breaking callers.153. **Return typed status codes, never raw errors.** A `fmt.Errorf` becomes `codes.Unknown` on the wire — the client cannot decide whether to retry.164. **Every client call has a deadline.** No `context.Background()` to a remote service. Set `context.WithTimeout` per call.175. **Reuse connections.** HTTP/2 multiplexes; creating a new `grpc.ClientConn` per request is a TLS handshake leak.186. **Disable reflection in production.** Reflection is a developer convenience that doubles as an API enumeration tool for attackers.1920## When to Use What2122| Need | Use |23|---|---|24| Define service | `.proto` file in `proto/<service>/v1/` |25| Generate stubs | `buf generate` or `protoc --go_out --go-grpc_out` |26| Cross-cutting (auth, logging, recovery) | `grpc.ChainUnaryInterceptor` / `ChainStreamInterceptor` |27| Health probes (Kubernetes) | `grpc_health_v1` from `google.golang.org/grpc/health` |28| Errors with details | `status.Errorf(codes.X, ...)` + `WithDetails(errdetails.BadRequest{...})` |29| Tests | `google.golang.org/grpc/test/bufconn` |30| Service mesh / mTLS | `credentials.NewTLS` or delegate to Istio/Linkerd |3132> Read [references/proto-and-codegen.md](../../../skills/go-grpc/references/proto-and-codegen.md) when organizing `.proto` packages or wiring `buf`.33> Read [references/status-and-errors.md](../../../skills/go-grpc/references/status-and-errors.md) when mapping domain errors to gRPC codes.3435## Server Bootstrap3637```go38import (39 "google.golang.org/grpc"40 "google.golang.org/grpc/health"41 healthpb "google.golang.org/grpc/health/grpc_health_v1"42)4344srv := grpc.NewServer(45 grpc.ChainUnaryInterceptor(recoveryUnary, loggingUnary, authUnary),46 grpc.ChainStreamInterceptor(recoveryStream, loggingStream),47)48pb.RegisterUserServiceServer(srv, &userService{...})49healthpb.RegisterHealthServer(srv, health.NewServer())5051go func() { _ = srv.Serve(lis) }()5253// Graceful shutdown bounded by a hard timeout.54<-shutdownSignal55stopped := make(chan struct{})56go func() { srv.GracefulStop(); close(stopped) }()57select {58case <-stopped:59case <-time.After(15 * time.Second):60 srv.Stop()61}62```6364Three pieces are non-negotiable: interceptors for cross-cutting concerns, health service for Kubernetes probes, and a bounded graceful shutdown.6566## Client Bootstrap6768```go69conn, _ := grpc.NewClient("dns:///user-service:50051",70 grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)),71 grpc.WithDefaultServiceConfig(`{72 "loadBalancingPolicy": "round_robin",73 "methodConfig": [{74 "name": [{"service": "user.v1.UserService"}],75 "timeout": "5s",76 "retryPolicy": {77 "maxAttempts": 3, "initialBackoff": "0.1s", "maxBackoff": "1s",78 "backoffMultiplier": 2, "retryableStatusCodes": ["UNAVAILABLE"]79 }80 }]81 }`),82)83client := pb.NewUserServiceClient(conn)84ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second); defer cancel()85resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: id})86```8788The service config is the right place for retries — let the library handle the loop, backoff, and `UNAVAILABLE`-only filter.8990## Errors9192A raw Go error returned from an RPC becomes `codes.Unknown`. The client cannot tell a 404 from a 500. Always use `status.Errorf`:9394```go95if errors.Is(err, ErrNotFound) {96 return nil, status.Errorf(codes.NotFound, "user %q not found", req.Id)97}98if errors.As(err, &validationErr) {99 st, _ := status.New(codes.InvalidArgument, "validation").WithDetails(100 &errdetails.BadRequest{FieldViolations: violations(validationErr)},101 )102 return nil, st.Err()103}104return nil, status.Errorf(codes.Internal, "lookup: %v", err)105```106107Quick map:108109| Domain | Code |110|---|---|111| Missing/invalid field | `InvalidArgument` |112| Not found | `NotFound` |113| Already exists | `AlreadyExists` |114| Unauthenticated | `Unauthenticated` |115| Authenticated but forbidden | `PermissionDenied` |116| Rate-limited | `ResourceExhausted` |117| Dependency down, retriable | `Unavailable` |118| Bug, unexpected | `Internal` |119120## Streaming121122| Pattern | Use case |123|---|---|124| Server streaming | Log tailing, paginated result sets, server-sent events |125| Client streaming | File upload, batch ingest |126| Bidirectional | Chat, real-time sync |127128Streams must respect `ctx.Done()`. A goroutine reading from a stream after the client disconnects is a slow leak.129130```go131func (s *server) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {132 for _, u := range s.repo.All(stream.Context()) {133 if err := stream.Send(toProto(u)); err != nil {134 return err // includes ctx canceled135 }136 }137 return nil138}139```140141## Testing with bufconn142143`bufconn` is an in-memory `net.Listener`. It exercises the real gRPC stack — interceptors, marshaling, metadata — without binding a TCP port. See [references/testing.md](../../../skills/go-grpc/references/testing.md) for the full harness plus table-driven status-code assertions, metadata injection, and stream testing.144145## Security Notes146147- TLS in production. Plaintext is only acceptable behind a confirmed-private network (and even then mTLS is preferable).148- For service-to-service auth, prefer a mesh (Istio/Linkerd) over hand-rolled token validation.149- For user auth, implement `credentials.PerRPCCredentials` to attach a token and validate inside an auth interceptor.150- Reflection: enable in dev, disable in prod via build tag or env flag.151152## Anti-Patterns153154| Anti-pattern | Why it hurts | Do this instead |155|---|---|---|156| `return fmt.Errorf("not found")` | Wire code is `Unknown`, clients can't retry-discriminate | `status.Errorf(codes.NotFound, ...)` |157| `context.Background()` to a client call | No deadline → goroutines pile up on a slow dependency | `context.WithTimeout(parent, 5s)` |158| New `ClientConn` per request | TLS handshake every call; sockets exhaust | One `grpc.NewClient` at startup, reuse |159| Bare `string` as RPC argument | Cannot add fields without breaking callers | Always Request/Response messages |160| Reflection on in production | Lets attackers enumerate every method | Compile-out with build tag in prod |161| `codes.Internal` for all errors | Client retry config can't distinguish bugs from outages | Map domain → specific codes |162| No health service | Kubernetes can't gate traffic; rolling deploys break | Register `grpc_health_v1` |163| Ignoring `stream.Context().Done()` | Goroutines run after client disconnect | Select on `ctx.Done()` in stream loops |164165## Verification Checklist166167- [ ] `.proto` packages are versioned (`pkg/v1`, not `pkg`)168- [ ] All RPCs take Request and return Response messages169- [ ] Generated code is in a separate directory, never edited170- [ ] Every error return uses `status.Errorf` with a specific code171- [ ] Every client call has a deadline via `context.WithTimeout`172- [ ] Server registers `grpc_health_v1`173- [ ] `GracefulStop` is bounded by a `time.After` fallback174- [ ] Reflection is gated to non-production builds175- [ ] Tests use `bufconn` and assert `status.Code(err)`176177## References178179- [references/proto-and-codegen.md](../../../skills/go-grpc/references/proto-and-codegen.md) — `.proto` layout, `buf.yaml`, codegen flags180- [references/status-and-errors.md](../../../skills/go-grpc/references/status-and-errors.md) — code mapping, rich details with `errdetails`181- [references/testing.md](../../../skills/go-grpc/references/testing.md) — `bufconn`, metadata, streaming assertions182- [references/anti-patterns.md](../../../skills/go-grpc/references/anti-patterns.md) — detailed walkthrough of each anti-pattern